├── .php_cs.dist ├── .styleci.yml ├── CHANGELOG.md ├── README.md ├── composer.json ├── src ├── Controller │ └── DefaultTransitionController.php ├── DependencyInjection │ ├── Compiler │ │ └── WorkflowPass.php │ ├── Configuration.php │ └── WesnickWorkflowExtension.php ├── EventListener │ ├── SubjectValidatorListener.php │ └── WorkflowOperationListener.php ├── Metadata │ └── WorkflowActionsResourceMetadataFactory.php ├── Model │ ├── Action.php │ ├── EntryPoint.php │ ├── PotentialActionInterface.php │ ├── PotentialActionsTrait.php │ └── WorkflowDTO.php ├── Resources │ ├── config │ │ ├── api_patch.xml │ │ ├── workflow.xml │ │ └── workflow_validation.xml │ └── meta │ │ └── LICENSE ├── Serializer │ ├── ActionsDocumentationNormalizer.php │ ├── WorkflowActionContextBuilder.php │ ├── WorkflowActionNormalizer.php │ └── WorkflowNormalizer.php ├── Transformer │ └── WorkflowDtoTransformer.php ├── Validation │ ├── ChainedWorkflowValidationStrategy.php │ ├── ValidationStateProviderInterface.php │ ├── ValidationStateProviderStrategy.php │ ├── WorkflowValidationStrategy.php │ └── WorkflowValidationStrategyInterface.php ├── WesnickWorkflowBundle.php └── WorkflowActionGenerator.php └── tests ├── Controller └── DefaultTransitionControllerTest.php ├── EventListener └── WorkflowOperationListenerTest.php ├── Fixtures ├── ArticleWithWorkflow.php └── StateProviderWithWorkflow.php ├── Metadata └── WorkflowActionsResourceMetadataFactoryTest.php └── Validation ├── ChainedWorkflowValidationStrategyTest.php ├── ValidationStateProviderStrategyTest.php └── WorkflowValidationStrategyTest.php /.php_cs.dist: -------------------------------------------------------------------------------- 1 | setRules([ 11 | '@Symfony' => true, 12 | 'array_syntax' => ['syntax' => 'short'], 13 | 'declare_strict_types' => true, 14 | 'combine_consecutive_unsets' => true, 15 | 'header_comment' => ['header' => $header], 16 | 'linebreak_after_opening_tag' => true, 17 | 'no_php4_constructor' => true, 18 | 'no_useless_else' => true, 19 | 'ordered_class_elements' => true, 20 | 'ordered_imports' => true, 21 | 'php_unit_construct' => true, 22 | 'php_unit_strict' => true, 23 | 'phpdoc_no_empty_return' => false, 24 | ]) 25 | ->setUsingCache(true) 26 | ->setRiskyAllowed(true) 27 | ->setFinder( 28 | PhpCsFixer\Finder::create() 29 | ->in(__DIR__) 30 | ) 31 | ; 32 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: symfony 2 | 3 | enabled: 4 | - alpha_ordered_imports 5 | - short_array_syntax 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WesnickWorkflowBundle 2 | ================== 3 | 4 | ### This bundle is a work-in-progress. 5 | 6 | [![Build Status](https://travis-ci.org/wesnick/api-platform-workflow-extension.svg?branch=master)](https://travis-ci.org/wesnick/api-platform-workflow-extension) 7 | [![Build Status](https://scrutinizer-ci.com/g/wesnick/api-platform-workflow-extension/badges/build.png?b=master)](https://scrutinizer-ci.com/g/wesnick/api-platform-workflow-extension/build-status/master) 8 | 9 | Introduction 10 | ------------ 11 | 12 | This bundle tries to allow relative easy integration between the API Platform and Symfony's Workflow component 13 | 14 | The goal is: 15 | - implement [actions as outlined in schema.org](https://schema.org/docs/actions.html) 16 | - automatically expose workflow information as part of the resource payload. 17 | - contains information about all available transitions 18 | - contains available but blocked transitions with ConstraintViolation messages 19 | - automatically expose endpoints to execute transitions. 20 | - workflows should try to do all mutations and persistence using listeners 21 | 22 | - Workflows that support API Platform resources can be toggled to include workflow data and operations 23 | - Workflow transition information is added to the resource representation as an array of [potentialAction](https://schema.org/docs/actions.html) 24 | ```json 25 | { 26 | "id": 1, 27 | "name": "Bob", 28 | "potentialAction": [ 29 | { 30 | "@type": "ControlAction", 31 | "target": { 32 | "@type": "Entrypoint", 33 | "httpMethod": "PATCH", 34 | "url": "/users/1?workflow=user_status&transition=ban_user" 35 | } 36 | } 37 | ] 38 | } 39 | 40 | ``` 41 | - If any potential action has a workflow transition blocker, these are displayed as an Api Platform ConstraintViolation class type under the property error 42 | ```json 43 | { 44 | "id": 1, 45 | "name": "Bob", 46 | "potentialAction": [ 47 | { 48 | "@type": "ControlAction", 49 | "name": "ban", 50 | "description": "Ban a User", 51 | "target": { 52 | "@type": "Entrypoint", 53 | "httpMethod": "PATCH", 54 | "url": "/users/1?workflow=user_status&transition=ban_user" 55 | }, 56 | "error": [{ 57 | "message": "User must be enabled before banning.", 58 | "propertyPath": "/enabled" 59 | }] 60 | } 61 | ] 62 | } 63 | ``` 64 | - The return type for workflows transition executions is always the subject. The subject will have updated potentialAction information as part of the response payload. If your transition succeeded 200, if it failed 400 with a ConstraintViolationList. 65 | - Depending on how you use workflows, you may or may not have data input for transitions. If there is no data, you can just PATCH a simple payload with the transition to the PATCH API endpoint. If your workflow transition requires data input, you can use the DTO feature to provide a custom input class 66 | 67 | ### Example 68 | Here are some example payloads with a basic user promote demote workflow 69 | 70 | GET a User 71 | ``` 72 | GET http://localhost/api/users/5 73 | Content-Type: application/ld+json 74 | 75 | { 76 | "@context": "\/api\/contexts\/User", 77 | "@id": "\/api\/users\/5", 78 | "@type": "User", 79 | "id": 5, 80 | "email": "erik.ledner@gmail.com", 81 | "lastLogin": null, 82 | "expired": false, 83 | "locked": false, 84 | "credentialsExpired": false, 85 | "enabled": true, 86 | "roles": [ 87 | "ROLE_ADMIN" 88 | ], 89 | "potentialAction": [ 90 | { 91 | "@context": "http:\/\/schema.org", 92 | "@type": "Action", 93 | "actionStatus": "PotentialActionStatus", 94 | "target": { 95 | "httpMethod": "PATCH", 96 | "url": "\/api\/users\/5?workflow=user_role&transition=promote_to_superadmin" 97 | }, 98 | "name": "promote_to_superadmin", 99 | "description": "Promote to Super Admin" 100 | }, 101 | { 102 | "@context": "http:\/\/schema.org", 103 | "@type": "Action", 104 | "actionStatus": "PotentialActionStatus", 105 | "target": { 106 | "httpMethod": "PATCH", 107 | "url": "\/api\/users\/5?workflow=user_role&transition=demote_to_user" 108 | }, 109 | "name": "demote_to_user", 110 | "description": "Demote to User" 111 | } 112 | ] 113 | } 114 | ``` 115 | Execute demote_to_user transition 116 | ``` 117 | PATCH http://localhost/api/users/5?workflow=user_role 118 | Content-Type: application/ld+json 119 | 120 | { 121 | "transition": "demote_to_user" 122 | } 123 | ``` 124 | Response 125 | ```json 126 | { 127 | "@context": "\/api\/contexts\/User", 128 | "@id": "\/api\/users\/5", 129 | "@type": "User", 130 | "id": 5, 131 | "email": "erik.ledner@gmail.com", 132 | "lastLogin": null, 133 | "expired": false, 134 | "locked": false, 135 | "credentialsExpired": false, 136 | "enabled": true, 137 | "roles": [ 138 | "ROLE_USER" 139 | ], 140 | "potentialAction": [ 141 | { 142 | "@context": "http:\/\/schema.org", 143 | "@type": "Action", 144 | "actionStatus": "PotentialActionStatus", 145 | "target": { 146 | "httpMethod": "PATCH", 147 | "url": "\/api\/users\/5?workflow=user_role&transition=promote_to_admin" 148 | }, 149 | "name": "promote_to_admin", 150 | "description": "Promote to Admin" 151 | }, 152 | { 153 | "@context": "http:\/\/schema.org", 154 | "@type": "Action", 155 | "actionStatus": "PotentialActionStatus", 156 | "target": { 157 | "httpMethod": "PATCH", 158 | "url": "\/api\/users\/5?workflow=user_role&transition=promote_to_superadmin" 159 | }, 160 | "name": "promote_to_superadmin", 161 | "description": "Promote to Super Admin" 162 | } 163 | ] 164 | } 165 | ``` 166 | 167 | 168 | Features 169 | -------- 170 | 171 | - Validation Helpers 172 | - React admin integration (WIP) 173 | 174 | Documentation 175 | ------------- 176 | 177 | - enable the module 178 | ```yml 179 | # config/packages/wesnick_workflow.yaml 180 | 181 | wesnick_workflow: 182 | api_patch_transitions: true # default 183 | workflow_validation_guard: true # default 184 | ``` 185 | 186 | - To enable API support for your workflows: 187 | 188 | - Subject class must implement PotentialActionInterface 189 | - You can implement this interface either with PotentialActionsTrait or on your own, be sure to set serialization groups appropriately. The bundle automatically pushes the group ```workflowAction:output``` during denormalization. 190 | - add descriptive messages for your workflow/transitions using workflow metadata 191 | 192 | 193 | 194 | Roadmap 195 | ------- 196 | 197 | License 198 | ------- 199 | 200 | This bundle is released under the MIT license. See the included 201 | [LICENSE](src/Resources/meta/LICENSE) file for more information. 202 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wesnick/api-platform-workflow-extension", 3 | "type": "symfony-bundle", 4 | "description": "Opinionated integration of api platform with workflow component", 5 | "keywords": [ "workflow", "api-platform" ], 6 | "homepage": "https://github.com/wesnick/api-platform-workflow-extension", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Wesley O. Nichols", 11 | "email": "spanishwes@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": "^7.1", 16 | "ext-json": "*", 17 | "api-platform/core": "^2.4", 18 | "symfony/workflow": "^3.4 || ^4.0", 19 | "symfony/validator": "^3.4 || ^4.0", 20 | "symfony/framework-bundle": "^3.4.4 || ^4.1.12" 21 | }, 22 | "require-dev": { 23 | "symfony/phpunit-bridge": "^4.2" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Wesnick\\WorkflowBundle\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Wesnick\\WorkflowBundle\\Tests\\": "tests/" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Controller/DefaultTransitionController.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class DefaultTransitionController 26 | { 27 | private $registry; 28 | 29 | public function __construct(Registry $registry) 30 | { 31 | $this->registry = $registry; 32 | } 33 | 34 | /** 35 | * @param WorkflowDTO $data 36 | * @param mixed $subject 37 | * @param string $workflowName 38 | * @param string $transitionName 39 | * 40 | * @return mixed 41 | */ 42 | public function __invoke($data, $subject, string $workflowName, string $transitionName) 43 | { 44 | if (!is_object($subject)) { 45 | throw new BadRequestHttpException( 46 | sprintf('Expected object for workflow "%s", got %s.', $workflowName, gettype($subject)) 47 | ); 48 | } 49 | 50 | try { 51 | $workflow = $this->registry->get($subject, $workflowName); 52 | 53 | if ($workflow->can($subject, $transitionName)) { 54 | // Symfony 4.3 added context to workflow transitions 55 | if (version_compare(Kernel::VERSION, '4.3.0', '<')) { 56 | $workflow->apply($subject, $transitionName); 57 | } else { 58 | $workflow->apply($subject, $transitionName, ['wesnick_workflow_dto' => $data]); 59 | } 60 | 61 | return $subject; 62 | } 63 | 64 | } catch (InvalidArgumentException $e) { 65 | throw new BadRequestHttpException($e->getMessage()); 66 | } 67 | 68 | throw new BadRequestHttpException( 69 | sprintf('Transition "%s" in Workflow "%s" is not available.', $transitionName, $workflowName) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/WorkflowPass.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class WorkflowPass implements CompilerPassInterface 28 | { 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function process(ContainerBuilder $container) 33 | { 34 | if (!$container->hasDefinition('workflow.registry')) { 35 | return; 36 | } 37 | // @TODO: not sure if required to add Models to resource class directories. 38 | // $directories = $container->getParameter('api_platform.resource_class_directories'); 39 | // $directories[] = realpath(__DIR__.'/../../Model'); 40 | // $container->setParameter('api_platform.resource_class_directories', $directories); 41 | 42 | $config = $container->getExtensionConfig('wesnick_workflow'); 43 | 44 | $classMap = []; 45 | 46 | // Iterate over workflows and create services 47 | foreach ($this->workflowGenerator($container) as [$workflow, $supportStrategy]) { 48 | // only support InstanceOfSupportStrategy for now 49 | if (InstanceOfSupportStrategy::class !== $supportStrategy->getClass()) { 50 | throw new \RuntimeException(sprintf('Wesnick Workflow Bundle requires use of InstanceOfSupportStrategy, workflow %s is using strategy %s', (string) $workflow, $supportStrategy->getClass())); 51 | } 52 | 53 | $className = $supportStrategy->getArgument(0); 54 | $workflowShortName = $workflow->getArgument(3); 55 | $classMap[$className][] = $workflowShortName; 56 | 57 | if ($config[0]['workflow_validation_guard']) { 58 | $container 59 | ->getDefinition(SubjectValidatorListener::class) 60 | ->addTag('kernel.event_listener', ['event' => 'workflow.'.$workflowShortName.'.guard', 'method' => 'onGuard']); 61 | } 62 | } 63 | 64 | $container->getDefinition(WorkflowActionGenerator::class)->setArgument('$enabledWorkflowMap', $classMap); 65 | $container->getDefinition(WorkflowOperationListener::class)->setArgument('$enabledWorkflowMap', $classMap); 66 | } 67 | 68 | private function workflowGenerator(ContainerBuilder $container): \Generator 69 | { 70 | $registry = $container->getDefinition('workflow.registry'); 71 | foreach ($registry->getMethodCalls() as $call) { 72 | [, [$workflowReference, $supportStrategy]] = $call; 73 | yield [$container->getDefinition($workflowReference), $supportStrategy]; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Configuration implements ConfigurationInterface 14 | { 15 | public function getConfigTreeBuilder() 16 | { 17 | $treeBuilder = new TreeBuilder('wesnick_workflow'); 18 | 19 | $treeBuilder->getRootNode() 20 | ->children() 21 | ->booleanNode('api_patch_transitions')->defaultTrue()->end() 22 | ->booleanNode('workflow_validation_guard')->defaultTrue()->end() 23 | ->end() 24 | ; 25 | 26 | return $treeBuilder; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/DependencyInjection/WesnickWorkflowExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 34 | 35 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 36 | $loader->load('workflow.xml'); 37 | if (true === $config['api_patch_transitions']) { 38 | $loader->load('api_patch.xml'); 39 | } 40 | 41 | if (true === $config['workflow_validation_guard']) { 42 | $loader->load('workflow_validation.xml'); 43 | // @TODO: add a tag 44 | $chainedValidator = $container->getDefinition(ChainedWorkflowValidationStrategy::class); 45 | $chainedValidator->setArgument(0, [ 46 | $container->getDefinition(WorkflowValidationStrategy::class), 47 | $container->getDefinition(ValidationStateProviderStrategy::class) 48 | ]); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/EventListener/SubjectValidatorListener.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class SubjectValidatorListener 25 | { 26 | private $validator; 27 | private $validationStrategy; 28 | 29 | /** 30 | * SubjectValidatorListener constructor. 31 | * 32 | * @param ValidatorInterface $validator 33 | * @param WorkflowValidationStrategyInterface $validationStrategy 34 | */ 35 | public function __construct(ValidatorInterface $validator, WorkflowValidationStrategyInterface $validationStrategy) 36 | { 37 | $this->validator = $validator; 38 | $this->validationStrategy = $validationStrategy; 39 | } 40 | 41 | /** 42 | * @param GuardEvent $event 43 | */ 44 | public function onGuard(GuardEvent $event) 45 | { 46 | $validationGroups = $this 47 | ->validationStrategy 48 | ->getValidationGroupsForSubject($event->getSubject(), $event->getWorkflow(), $event->getTransition()) 49 | ; 50 | 51 | $violations = $this->validator->validate($event->getSubject(), null, $validationGroups); 52 | 53 | foreach ($violations as $violation) { 54 | $event->addTransitionBlocker( 55 | new TransitionBlocker( 56 | $violation->getMessage(), 57 | $violation->getCode(), 58 | $violation->getParameters() + ['original_violation' => $violation] 59 | ) 60 | ); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/EventListener/WorkflowOperationListener.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class WorkflowOperationListener 24 | { 25 | /** 26 | * Classmap. 27 | * 28 | * [ className => [workflowName]] 29 | * 30 | * @var array 31 | */ 32 | private $enabledWorkflowMap; 33 | 34 | public function __construct(array $enabledWorkflowMap) 35 | { 36 | $this->enabledWorkflowMap = $enabledWorkflowMap; 37 | } 38 | 39 | /** 40 | * @param GetResponseEvent $event 41 | */ 42 | public function onKernelRequest(GetResponseEvent $event) 43 | { 44 | $request = $event->getRequest(); 45 | if (!$request->isMethod(Request::METHOD_PATCH) 46 | || !($attributes = RequestAttributesExtractor::extractAttributes($request)) 47 | || 'patch' !== $attributes['item_operation_name'] ?? null 48 | || !array_key_exists($attributes['resource_class'] ?? 'n/a', $this->enabledWorkflowMap) 49 | ) { 50 | return; 51 | } 52 | 53 | $requestContent = json_decode($request->getContent()); 54 | // Set the data attribute as subject, since the DTO will be deserialized to the data attribute 55 | $request->attributes->set('subject', $request->attributes->get('data')); 56 | $request->attributes->set('workflowName', $request->query->get('workflow', '')); 57 | $request->attributes->set('transitionName', $requestContent->transition ?? ''); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Metadata/WorkflowActionsResourceMetadataFactory.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class WorkflowActionsResourceMetadataFactory implements ResourceMetadataFactoryInterface 26 | { 27 | private $decorated; 28 | 29 | public function __construct(ResourceMetadataFactoryInterface $decorated) 30 | { 31 | $this->decorated = $decorated; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function create(string $resourceClass): ResourceMetadata 38 | { 39 | $resourceMetadata = $this->decorated->create($resourceClass); 40 | if (!is_a($resourceClass, PotentialActionInterface::class, true)) { 41 | return $resourceMetadata; 42 | } 43 | 44 | // Set the pseudo-group for potentialAction to appear in name collection metadata factories 45 | $attributes = $resourceMetadata->getAttributes(); 46 | $groups = $attributes['denormalization_context']['groups'] ?? []; 47 | if (!in_array('workflowAction:output', $groups, true)) { 48 | $attributes['denormalization_context']['groups'][] = 'workflowAction:output'; 49 | } 50 | 51 | $operations = $resourceMetadata->getItemOperations(); 52 | $operations['patch'] = [ 53 | 'method' => 'PATCH', 54 | 'controller' => DefaultTransitionController::class, 55 | 'input' => ['class' => WorkflowDTO::class, 'name' => 'WorkflowDTO'], 56 | ]; 57 | 58 | return $resourceMetadata 59 | ->withAttributes($attributes) 60 | ->withItemOperations($operations) 61 | ; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Model/Action.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | class Action 29 | { 30 | /** 31 | * @var string|null indicates the current disposition of the Action 32 | * 33 | * @ApiProperty(iri="http://schema.org/actionStatus") 34 | * @Groups({"workflowAction:output"}) 35 | * @Assert\Type(type="string") 36 | * @Assert\Choice(choices={ 37 | * "PotentialActionStatus", 38 | * "ActiveActionStatus", 39 | * "FailedActionStatus", 40 | * "CompletedActionStatus" 41 | * }) 42 | */ 43 | private $actionStatus = 'PotentialActionStatus'; 44 | 45 | /** 46 | * @var EntryPoint|null indicates a target EntryPoint for an Action 47 | * 48 | * @ApiProperty(iri="http://schema.org/target") 49 | * @Groups({"workflowAction:output"}) 50 | */ 51 | private $target; 52 | 53 | /** 54 | * @var string|null the name of the item 55 | * 56 | * @ApiProperty(iri="http://schema.org/name") 57 | * @Groups({"workflowAction:output"}) 58 | * @Assert\Type(type="string") 59 | */ 60 | private $name; 61 | 62 | /** 63 | * @var string|null a description of the item 64 | * 65 | * @ApiProperty(iri="http://schema.org/description") 66 | * @Groups({"workflowAction:output"}) 67 | * @Assert\Type(type="string") 68 | */ 69 | private $description; 70 | 71 | /** 72 | * @var ConstraintViolationListInterface|null 73 | * 74 | * @ApiProperty(iri="http://schema.org/error") 75 | * @Groups({"workflowAction:output"}) 76 | * 77 | * @var ConstraintViolationList 78 | */ 79 | private $error; 80 | 81 | public function setActionStatus(?string $actionStatus): void 82 | { 83 | $this->actionStatus = $actionStatus; 84 | } 85 | 86 | public function getActionStatus(): ?string 87 | { 88 | return $this->actionStatus; 89 | } 90 | 91 | /** 92 | * @return EntryPoint|null 93 | */ 94 | public function getTarget(): ?EntryPoint 95 | { 96 | return $this->target; 97 | } 98 | 99 | /** 100 | * @param EntryPoint $target 101 | */ 102 | public function setTarget(EntryPoint $target): void 103 | { 104 | $this->target = $target; 105 | } 106 | 107 | /** 108 | * @return string|null 109 | */ 110 | public function getName(): ?string 111 | { 112 | return $this->name; 113 | } 114 | 115 | /** 116 | * @param string|null $name 117 | */ 118 | public function setName(?string $name): void 119 | { 120 | $this->name = $name; 121 | } 122 | 123 | /** 124 | * @return string|null 125 | */ 126 | public function getDescription(): ?string 127 | { 128 | return $this->description; 129 | } 130 | 131 | /** 132 | * @param string|null $description 133 | */ 134 | public function setDescription(?string $description): void 135 | { 136 | $this->description = $description; 137 | } 138 | 139 | /** 140 | * @return ConstraintViolationListInterface|null 141 | */ 142 | public function getError(): ?ConstraintViolationListInterface 143 | { 144 | return $this->error; 145 | } 146 | 147 | /** 148 | * @param ConstraintViolationInterface $error 149 | */ 150 | public function addError(ConstraintViolationInterface $error): void 151 | { 152 | if (null === $this->error) { 153 | $this->error = new ConstraintViolationList(); 154 | } 155 | 156 | $this->error->add($error); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Model/EntryPoint.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class EntryPoint 25 | { 26 | /** 27 | * @var string|null An HTTP method that specifies the appropriate HTTP method for a request to an HTTP EntryPoint. Values are capitalized strings as used in HTTP. 28 | * 29 | * @ApiProperty(iri="http://schema.org/httpMethod") 30 | * @Assert\Type(type="string") 31 | */ 32 | private $httpMethod; 33 | 34 | /** 35 | * @var string|null an url template (RFC6570) that will be used to construct the target of the execution of the action 36 | * 37 | * @ApiProperty(iri="http://schema.org/urlTemplate") 38 | * @Assert\Type(type="string") 39 | */ 40 | private $urlTemplate; 41 | 42 | /** 43 | * @var string|null URL of the item 44 | * 45 | * @ApiProperty(iri="http://schema.org/url") 46 | * @Assert\Url 47 | */ 48 | private $url; 49 | 50 | public function setHttpMethod(?string $httpMethod): void 51 | { 52 | $this->httpMethod = $httpMethod; 53 | } 54 | 55 | public function getHttpMethod(): ?string 56 | { 57 | return $this->httpMethod; 58 | } 59 | 60 | public function setUrlTemplate(?string $urlTemplate): void 61 | { 62 | $this->urlTemplate = $urlTemplate; 63 | } 64 | 65 | public function getUrlTemplate(): ?string 66 | { 67 | return $this->urlTemplate; 68 | } 69 | 70 | public function setUrl(?string $url): void 71 | { 72 | $this->url = $url; 73 | } 74 | 75 | public function getUrl(): ?string 76 | { 77 | return $this->url; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Model/PotentialActionInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface PotentialActionInterface 20 | { 21 | /** 22 | * @param Action $action 23 | */ 24 | public function addPotentialAction(Action $action); 25 | 26 | /** 27 | * @return Action[] 28 | */ 29 | public function getPotentialAction(): array; 30 | } 31 | -------------------------------------------------------------------------------- /src/Model/PotentialActionsTrait.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | trait PotentialActionsTrait 23 | { 24 | /** 25 | * @var Action[] collection of potential Action, which describes an idealized action in which this thing 26 | * would play an 'object' role 27 | * 28 | * @ApiProperty( 29 | * iri="http://schema.org/potentialAction", 30 | * readable=true, 31 | * writable=false 32 | * ) 33 | * @Groups({"workflowAction:output"}) 34 | */ 35 | private $potentialAction = []; 36 | 37 | /** 38 | * @param Action $action 39 | */ 40 | public function addPotentialAction(Action $action) 41 | { 42 | $this->potentialAction[] = $action; 43 | } 44 | 45 | /** 46 | * @return array 47 | */ 48 | public function getPotentialAction(): array 49 | { 50 | return $this->potentialAction; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Model/WorkflowDTO.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class WorkflowDTO 20 | { 21 | /** 22 | * @var string 23 | */ 24 | protected $transition; 25 | 26 | /** 27 | * @return string 28 | */ 29 | public function getTransition(): string 30 | { 31 | return $this->transition; 32 | } 33 | 34 | /** 35 | * @param string $transition 36 | */ 37 | public function setTransition(string $transition): void 38 | { 39 | $this->transition = $transition; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Resources/config/api_patch.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/Resources/config/workflow.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Resources/config/workflow_validation.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Resources/meta/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Wesley O. Nichols 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Serializer/ActionsDocumentationNormalizer.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class ActionsDocumentationNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface 24 | { 25 | public const FORMAT = 'jsonld'; 26 | 27 | private $decorated; 28 | 29 | public function __construct(DocumentationNormalizer $decorated) 30 | { 31 | $this->decorated = $decorated; 32 | } 33 | 34 | public function hasCacheableSupportsMethod(): bool 35 | { 36 | return true; 37 | } 38 | 39 | public function normalize($object, $format = null, array $context = []) 40 | { 41 | // @TODO: review that this is even gets hydra to do anything extra 42 | $data = $this->decorated->normalize($object, $format, $context); 43 | 44 | // Add in our empty payload class 45 | $data['hydra:supportedClass'][] = [ 46 | '@id' => '#WorkflowDTO', 47 | '@type' => 'hydra:Class', 48 | 'hydra:title' => 'WorkflowDTO', 49 | 'hydra:label' => 'WorkflowDTO', 50 | 'hydra:description' => 'Represents workflow name and transition.', 51 | ]; 52 | 53 | return $data; 54 | } 55 | 56 | public function supportsNormalization($data, $format = null) 57 | { 58 | return $this->decorated->supportsNormalization($data, $format); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Serializer/WorkflowActionContextBuilder.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class WorkflowActionContextBuilder implements SerializerContextBuilderInterface 24 | { 25 | private $decorated; 26 | 27 | public function __construct(SerializerContextBuilderInterface $decorated) 28 | { 29 | $this->decorated = $decorated; 30 | } 31 | 32 | public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array 33 | { 34 | $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); 35 | $resourceClass = $context['resource_class'] ?? null; 36 | 37 | if (is_a($resourceClass, PotentialActionInterface::class, true) 38 | && isset($context['groups']) 39 | && true === $normalization 40 | ) { 41 | $context['groups'][] = 'workflowAction:output'; 42 | } 43 | 44 | return $context; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Serializer/WorkflowActionNormalizer.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class WorkflowActionNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface 25 | { 26 | private $customNormalizer; 27 | 28 | public function __construct(NormalizerInterface $customNormalizer) 29 | { 30 | $this->customNormalizer = $customNormalizer; 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function supportsNormalization($data, $format = null): bool 37 | { 38 | return $data instanceof Action || $data instanceof EntryPoint; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function hasCacheableSupportsMethod(): bool 45 | { 46 | return true; 47 | } 48 | 49 | public function normalize($object, $format = null, array $context = []) 50 | { 51 | if ($object instanceof Action) { 52 | $resourceClass = get_class($object); 53 | $resourceShortName = substr($resourceClass, strrpos($resourceClass, '\\') + 1); 54 | 55 | return [ 56 | '@context' => 'http://schema.org', 57 | '@type' => $resourceShortName, 58 | ] + array_filter($this->customNormalizer->normalize($object, 'json')); 59 | } elseif ($object instanceof EntryPoint) { 60 | $data = array_filter($this->customNormalizer->normalize($object, 'json')); 61 | // EntryPoint can be represented as an object or a string in case only url property is present 62 | if (1 === count($data) && array_key_exists('url', $data)) { 63 | return $data['url']; 64 | } 65 | 66 | return $data; 67 | } 68 | 69 | return null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Serializer/WorkflowNormalizer.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | class WorkflowNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface, ContextAwareDenormalizerInterface, SerializerAwareInterface 29 | { 30 | /** 31 | * @var ItemNormalizer 32 | */ 33 | private $decorated; 34 | private $customNormalizer; 35 | private $workflowActions; 36 | 37 | public function __construct( 38 | NormalizerInterface $decorated, 39 | NormalizerInterface $customNormalizer, 40 | WorkflowActionGenerator $workflowActions 41 | ) { 42 | $this->decorated = $decorated; 43 | $this->customNormalizer = $customNormalizer; 44 | $this->workflowActions = $workflowActions; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function supportsNormalization($data, $format = null): bool 51 | { 52 | return $this->decorated->supportsNormalization($data, $format); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function hasCacheableSupportsMethod(): bool 59 | { 60 | return true; 61 | } 62 | 63 | public function normalize($object, $format = null, array $context = []) 64 | { 65 | if ($object instanceof PotentialActionInterface) { 66 | $actions = $this->workflowActions->getActionsForSubject($object); 67 | foreach ($actions as $action) { 68 | $object->addPotentialAction($action); 69 | } 70 | } 71 | 72 | return $this->decorated->normalize($object, $format, $context); 73 | } 74 | 75 | public function supportsDenormalization($data, $type, $format = null, array $context = []) 76 | { 77 | return $this->decorated->supportsDenormalization($data, $type, $format, $context); 78 | } 79 | 80 | public function denormalize($data, $class, $format = null, array $context = []) 81 | { 82 | return $this->decorated->denormalize($data, $class, $format, $context); 83 | } 84 | 85 | public function setSerializer(SerializerInterface $serializer) 86 | { 87 | $this->decorated->setSerializer($serializer); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Transformer/WorkflowDtoTransformer.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class WorkflowDtoTransformer implements DataTransformerInterface 23 | { 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function transform($data, string $to, array $context = []) 28 | { 29 | return $data; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function supportsTransformation($data, string $to, array $context = []): bool 36 | { 37 | if (is_object($data)) { 38 | return false; 39 | } 40 | 41 | return WorkflowDTO::class === ($context['input']['class'] ?? null); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Validation/ChainedWorkflowValidationStrategy.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ChainedWorkflowValidationStrategy implements WorkflowValidationStrategyInterface 23 | { 24 | /** 25 | * @var WorkflowValidationStrategyInterface[] 26 | */ 27 | private $strategies; 28 | 29 | /** 30 | * ChainedWorkflowValidationStrategy constructor. 31 | * 32 | * @param array $strategies 33 | */ 34 | public function __construct(array $strategies) 35 | { 36 | $this->strategies = $strategies; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function getValidationGroupsForSubject($subject, Workflow $workflow, Transition $transition): array 43 | { 44 | $groups = []; 45 | 46 | foreach ($this->strategies as $strategy) { 47 | $groups = array_merge($groups, $strategy->getValidationGroupsForSubject($subject, $workflow, $transition)); 48 | } 49 | 50 | return array_unique($groups); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Validation/ValidationStateProviderInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface ValidationStateProviderInterface 20 | { 21 | /** 22 | * Return validation groups for this subject based on a proposed state property. 23 | * 24 | * This method should not alter the state of the subject. 25 | * 26 | * @param string $state 27 | * @param string $workflowName 28 | * 29 | * @return array 30 | */ 31 | public function getGroupSequenceForState(string $state, string $workflowName): array; 32 | } 33 | -------------------------------------------------------------------------------- /src/Validation/ValidationStateProviderStrategy.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ValidationStateProviderStrategy implements WorkflowValidationStrategyInterface 23 | { 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function getValidationGroupsForSubject($subject, Workflow $workflow, Transition $transition): array 28 | { 29 | $groups = []; 30 | 31 | if ($subject instanceof ValidationStateProviderInterface) { 32 | foreach ($transition->getTos() as $state) { 33 | $groups = array_merge($groups, $subject->getGroupSequenceForState($state, $workflow->getName())); 34 | } 35 | } 36 | 37 | return $groups; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Validation/WorkflowValidationStrategy.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class WorkflowValidationStrategy implements WorkflowValidationStrategyInterface 23 | { 24 | public function getValidationGroupsForSubject($subject, Workflow $workflow, Transition $transition): array 25 | { 26 | $groups = array_map(function ($state) use ($workflow) { 27 | return $workflow->getName().'_'.$state; 28 | }, $transition->getTos()); 29 | 30 | array_unshift($groups, 'Default', $workflow->getName()); 31 | 32 | return $groups; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Validation/WorkflowValidationStrategyInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | interface WorkflowValidationStrategyInterface 23 | { 24 | /** 25 | * Return the validation groups that should be used to validate a subject for a given transition in a given workflow. 26 | * 27 | * @param object $subject 28 | * @param Workflow $workflow 29 | * @param Transition $transition 30 | * 31 | * @return array 32 | */ 33 | public function getValidationGroupsForSubject($subject, Workflow $workflow, Transition $transition): array; 34 | } 35 | -------------------------------------------------------------------------------- /src/WesnickWorkflowBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new WorkflowPass()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/WorkflowActionGenerator.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class WorkflowActionGenerator 28 | { 29 | private $registry; 30 | private $iriConverter; 31 | 32 | /** 33 | * Classmap. 34 | * 35 | * [ className => [workflowName]] 36 | * 37 | * @var array 38 | */ 39 | private $enabledWorkflowMap; 40 | 41 | /** 42 | * WorkflowActionGenerator constructor. 43 | * 44 | * @param $registry 45 | * @param $iriConverter 46 | * @param array $enabledWorkflowMap 47 | */ 48 | public function __construct(Registry $registry, IriConverterInterface $iriConverter, array $enabledWorkflowMap) 49 | { 50 | $this->registry = $registry; 51 | $this->iriConverter = $iriConverter; 52 | $this->enabledWorkflowMap = $enabledWorkflowMap; 53 | } 54 | 55 | public function getActionsForSubject($subject) 56 | { 57 | $workflows = $this->registry->all($subject); 58 | $actions = []; 59 | 60 | foreach ($workflows as $workflow) { 61 | 62 | $possibleTransitions = []; 63 | $marking = $workflow->getMarking($subject); 64 | 65 | foreach ($workflow->getDefinition()->getTransitions() as $transition) { 66 | $froms = array_flip($transition->getFroms()); 67 | $intersection = array_intersect_key($froms, $marking->getPlaces()); 68 | if ($intersection) { 69 | $possibleTransitions[] = $transition; 70 | } 71 | } 72 | 73 | foreach ($possibleTransitions as $transition) { 74 | $transitionBlockerList = $workflow->buildTransitionBlockerList($subject, $transition->getName()); 75 | 76 | $transitionMeta = $workflow->getMetadataStore()->getTransitionMetadata($transition); 77 | 78 | $url = sprintf( 79 | '%s?%s', 80 | $this->iriConverter->getIriFromItem($subject, UrlGeneratorInterface::ABS_URL), 81 | http_build_query([ 82 | 'workflow' => $workflow->getName(), 83 | 'transition' => $transition->getName(), 84 | ]) 85 | ); 86 | 87 | $entryPoint = new EntryPoint(); 88 | $entryPoint->setUrl($url); 89 | $entryPoint->setHttpMethod('PATCH'); 90 | 91 | $currentAction = new Action(); 92 | $currentAction->setTarget($entryPoint); 93 | $currentAction->setName($transition->getName()); 94 | $currentAction->setDescription($transitionMeta['description'] ?? ucfirst($transition->getName()).' Action'); 95 | // @TODO: add sub status (available, unavailable, access denied, invalid) 96 | 97 | foreach ($transitionBlockerList as $blocker) { 98 | $parameters = $blocker->getParameters(); 99 | 100 | if (array_key_exists('original_violation', $parameters)) { 101 | $violation = $parameters['original_violation']; 102 | } else { 103 | // @TODO: add a factory or event for building Violations from TransitionBlockers 104 | $violation = new ConstraintViolation( 105 | $blocker->getMessage(), 106 | $blocker->getMessage(), 107 | $blocker->getParameters(), 108 | $subject, 109 | '/', 110 | '' 111 | ); 112 | } 113 | 114 | $currentAction->addError($violation); 115 | } 116 | 117 | $actions[] = $currentAction; 118 | } 119 | } 120 | 121 | return $actions; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Controller/DefaultTransitionControllerTest.php: -------------------------------------------------------------------------------- 1 | createComplexWorkflowDefinition(); 34 | $workflow = new Workflow($definition, new MultipleStateMarkingStore()); 35 | 36 | $registry = $this->createRegistry($workflow); 37 | $controller = new DefaultTransitionController($registry->reveal()); 38 | 39 | $this->expectException(BadRequestHttpException::class); 40 | $this->expectExceptionMessage('Expected object for workflow "workflow", got array'); 41 | $controller(new WorkflowDTO(), [], 'workflow', 'transition'); 42 | } 43 | 44 | /** 45 | * @dataProvider workflowProvider 46 | */ 47 | public function test__invoke($workflow, $subject, $transition, bool $success) 48 | { 49 | $registry = $this->createRegistry($workflow, $success); 50 | $controller = new DefaultTransitionController($registry->reveal()); 51 | 52 | if (!$success) { 53 | $this->expectException(BadRequestHttpException::class); 54 | $this->expectExceptionMessage(sprintf('Transition "%s" in Workflow "workflow" is not available.', $transition)); 55 | } 56 | 57 | $result = $controller(new WorkflowDTO(), $subject, 'workflow', $transition); 58 | 59 | $this->assertSame($subject, $result); 60 | $this->assertFalse($workflow->can($subject, $transition)); 61 | } 62 | 63 | public function workflowProvider() 64 | { 65 | $definition = $this->createComplexWorkflowDefinition(); 66 | $workflow = new Workflow($definition, new MultipleStateMarkingStore()); 67 | 68 | $subject = new \stdClass(); 69 | $subject->marking = null; 70 | yield [$workflow, $subject, 't1', true]; 71 | 72 | $subject = new \stdClass(); 73 | $subject->marking = null; 74 | yield [$workflow, $subject, 't2', false]; 75 | 76 | $subject = new \stdClass(); 77 | $subject->marking = ['b' => 1]; 78 | yield [$workflow, $subject, 't1', false]; 79 | 80 | $subject = new \stdClass(); 81 | $subject->marking = ['b' => 1]; 82 | yield [$workflow, $subject, 't2', false]; 83 | 84 | $subject = new \stdClass(); 85 | $subject->marking = ['b' => 1, 'c' => 1]; 86 | yield [$workflow, $subject, 't1', false]; 87 | 88 | $subject = new \stdClass(); 89 | $subject->marking = ['b' => 1, 'c' => 1]; 90 | yield [$workflow, $subject, 't2', true]; 91 | 92 | $subject = new \stdClass(); 93 | $subject->marking = ['f' => 1]; 94 | yield [$workflow, $subject, 't5', false]; 95 | 96 | $subject = new \stdClass(); 97 | $subject->marking = ['f' => 1]; 98 | yield [$workflow, $subject, 't6', true]; 99 | } 100 | 101 | private function createRegistry(Workflow $workflow) 102 | { 103 | $registry = $this->prophesize(Registry::class); 104 | $registry->get(Argument::type(\stdClass::class), Argument::type('string')) 105 | ->willReturn($workflow) 106 | ; 107 | 108 | return $registry; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/EventListener/WorkflowOperationListenerTest.php: -------------------------------------------------------------------------------- 1 | [ 24 | 'workflow', 25 | 'another_workflow', 26 | ], 27 | ]; 28 | 29 | /** 30 | * @dataProvider eventProvider 31 | */ 32 | public function testOnKernelRequest($method, $data, $workflowName, $transitionName, $query, $body, $hasParams) 33 | { 34 | $eventProphecy = $this->prophesize(GetResponseEvent::class); 35 | 36 | $request = new Request($query, [], ['data' => $data, '_api_resource_class' => ArticleWithWorkflow::class, '_api_item_operation_name' => strtolower($method)], [], [], [], $body); 37 | $request->setMethod($method); 38 | $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); 39 | 40 | $listener = new WorkflowOperationListener(self::$classmap); 41 | $listener->onKernelRequest($eventProphecy->reveal()); 42 | 43 | if ($hasParams) { 44 | $this->assertSame($request->attributes->get('subject'), $data); 45 | $this->assertSame($request->attributes->get('workflowName'), $workflowName); 46 | $this->assertSame($request->attributes->get('transitionName'), $transitionName); 47 | } else { 48 | $this->assertFalse($request->attributes->has('subject')); 49 | $this->assertFalse($request->attributes->has('workflowName')); 50 | $this->assertFalse($request->attributes->has('transitionName')); 51 | } 52 | } 53 | 54 | public function eventProvider() 55 | { 56 | yield ['GET', new \stdClass(), 'workflow', 'transition', ['workflow' => 'workflow'], json_encode(['transition' => 'transition']), false]; 57 | yield ['POST', new \stdClass(), 'workflow', 'transition', [], [], false]; 58 | yield ['PATCH', new ArticleWithWorkflow(), 'workflow', 'transition', ['workflow' => 'workflow'], json_encode(['transition' => 'transition']), true]; 59 | yield ['PATCH', new ArticleWithWorkflow(), 'workflow', '', ['workflow' => 'workflow'], json_encode(['xxx' => 'transition']), true]; 60 | yield ['PATCH', new ArticleWithWorkflow(), 'non-existing-workflow-is-ok', '', ['workflow' => 'non-existing-workflow-is-ok'], json_encode(['xxx' => 'transition']), true]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Fixtures/ArticleWithWorkflow.php: -------------------------------------------------------------------------------- 1 | 22 | * @ApiResource() 23 | */ 24 | class ArticleWithWorkflow implements PotentialActionInterface 25 | { 26 | use PotentialActionsTrait; 27 | } 28 | -------------------------------------------------------------------------------- /tests/Fixtures/StateProviderWithWorkflow.php: -------------------------------------------------------------------------------- 1 | 23 | * @ApiResource() 24 | */ 25 | class StateProviderWithWorkflow implements ValidationStateProviderInterface 26 | { 27 | private $discriminator; 28 | 29 | /** 30 | * StateProviderWithWorkflow constructor. 31 | * @param string $discriminator 32 | */ 33 | public function __construct(string $discriminator) 34 | { 35 | $this->discriminator = $discriminator; 36 | } 37 | 38 | 39 | public function getGroupSequenceForState(string $state, string $workflowName): array 40 | { 41 | return [ 42 | sprintf('%s_%s', $state, $this->discriminator) 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Metadata/WorkflowActionsResourceMetadataFactoryTest.php: -------------------------------------------------------------------------------- 1 | prophesize(ResourceMetadataFactoryInterface::class); 33 | $decoratedProphecy->create(ArticleWithWorkflow::class)->shouldBeCalled()->willReturn($before); 34 | $this->assertEquals($after, (new WorkflowActionsResourceMetadataFactory($decoratedProphecy->reveal()))->create(ArticleWithWorkflow::class)); 35 | } 36 | 37 | public function getMetadata() 38 | { 39 | $operations = ['patch' => [ 40 | 'method' => 'PATCH', 41 | 'controller' => DefaultTransitionController::class, 42 | 'input' => ['class' => WorkflowDTO::class, 'name' => 'WorkflowDTO'], 43 | ]]; 44 | $attributes = ['denormalization_context' => ['groups' => ['workflowAction:output']]]; 45 | 46 | return [ 47 | // Item operations 48 | [ 49 | new ResourceMetadata(null, null, null, null, [], null, [], []), 50 | new ResourceMetadata(null, null, null, $operations, [], $attributes, [], []), 51 | ], 52 | [ 53 | new ResourceMetadata(null, null, null, ['patch' => []], [], null, [], []), 54 | new ResourceMetadata(null, null, null, $operations, [], $attributes, [], []), 55 | ], 56 | [ 57 | new ResourceMetadata(null, null, null, [], [], ['denormalization_context' => ['groups' => ['workflowAction:output']]], [], []), 58 | new ResourceMetadata(null, null, null, $operations, [], $attributes, [], []), 59 | ], 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Validation/ChainedWorkflowValidationStrategyTest.php: -------------------------------------------------------------------------------- 1 | getValidationGroupsForSubject($subject, $workflow, $transition); 31 | $this->assertEquals($result, $groups); 32 | } 33 | 34 | public function validationSubjectProvider() 35 | { 36 | $definition = $this->createComplexWorkflowDefinition(); 37 | $transitions = $definition->getTransitions(); 38 | $getTransitionByName = function ($name) use ($transitions) { 39 | foreach ($transitions as $transition) { 40 | if ($transition->getName() === $name) { 41 | return $transition; 42 | } 43 | } 44 | }; 45 | $workflow = new Workflow($definition, new MultipleStateMarkingStore()); 46 | 47 | yield [new ArticleWithWorkflow(), $workflow, $getTransitionByName('t1'), ['Default', 'unnamed', 'unnamed_b', 'unnamed_c']]; 48 | yield [new ArticleWithWorkflow(), $workflow, $getTransitionByName('t2'), ['Default', 'unnamed', 'unnamed_d']]; 49 | yield [new StateProviderWithWorkflow('blue'), $workflow, $getTransitionByName('t1'), ['Default', 'unnamed', 'unnamed_b', 'unnamed_c', 'b_blue', 'c_blue']]; 50 | yield [new StateProviderWithWorkflow('red'), $workflow, $getTransitionByName('t1'), ['Default', 'unnamed', 'unnamed_b', 'unnamed_c', 'b_red', 'c_red']]; 51 | yield [new StateProviderWithWorkflow('red'), $workflow, $getTransitionByName('t2'), ['Default', 'unnamed', 'unnamed_d', 'd_red']]; 52 | yield [new StateProviderWithWorkflow('green'), $workflow, $getTransitionByName('t2'), ['Default', 'unnamed', 'unnamed_d', 'd_green']]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Validation/ValidationStateProviderStrategyTest.php: -------------------------------------------------------------------------------- 1 | getValidationGroupsForSubject($subject, $workflow, $transition); 24 | $this->assertEquals($result, $groups); 25 | } 26 | 27 | public function validationSubjectProvider() 28 | { 29 | $definition = $this->createComplexWorkflowDefinition(); 30 | $transitions = $definition->getTransitions(); 31 | $getTransitionByName = function ($name) use ($transitions) { 32 | foreach ($transitions as $transition) { 33 | if ($transition->getName() === $name) { 34 | return $transition; 35 | } 36 | } 37 | }; 38 | $workflow = new Workflow($definition, new MultipleStateMarkingStore()); 39 | 40 | yield [new \stdClass(), $workflow, $getTransitionByName('t1'), []]; 41 | yield [new StateProviderWithWorkflow('blue'), $workflow, $getTransitionByName('t1'), ['b_blue', 'c_blue']]; 42 | yield [new StateProviderWithWorkflow('red'), $workflow, $getTransitionByName('t1'), ['b_red', 'c_red']]; 43 | yield [new StateProviderWithWorkflow('red'), $workflow, $getTransitionByName('t2'), ['d_red']]; 44 | yield [new StateProviderWithWorkflow('green'), $workflow, $getTransitionByName('t2'), ['d_green']]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Validation/WorkflowValidationStrategyTest.php: -------------------------------------------------------------------------------- 1 | getValidationGroupsForSubject($subject, $workflow, $transition); 24 | $this->assertEquals($result, $groups); 25 | } 26 | 27 | public function validationSubjectProvider() 28 | { 29 | $definition = $this->createComplexWorkflowDefinition(); 30 | $transitions = $definition->getTransitions(); 31 | $getTransitionByName = function ($name) use ($transitions) { 32 | foreach ($transitions as $transition) { 33 | if ($transition->getName() === $name) { 34 | return $transition; 35 | } 36 | } 37 | }; 38 | $workflow = new Workflow($definition, new MultipleStateMarkingStore()); 39 | 40 | yield [new ArticleWithWorkflow(), $workflow, $getTransitionByName('t1'), ['Default', 'unnamed', 'unnamed_b', 'unnamed_c']]; 41 | yield [new ArticleWithWorkflow(), $workflow, $getTransitionByName('t2'), ['Default', 'unnamed', 'unnamed_d']]; 42 | } 43 | } 44 | --------------------------------------------------------------------------------