diff --git a/phpstan.neon b/phpstan.neon index 5778e1c1e..55286e47f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,6 +17,7 @@ parameters: - %currentWorkingDirectory%/src/Bundle/DependencyInjection/Driver/Doctrine/DoctrinePHPCRDriver.php - %currentWorkingDirectory%/src/Bundle/Doctrine/ODM/* - %currentWorkingDirectory%/src/Bundle/EventListener/ODM* + - %currentWorkingDirectory%/src/Bundle/Event/ResourceControllerEvent.php - %currentWorkingDirectory%/src/Bundle/Form/Extension/HttpFoundation/HttpFoundationRequestHandler.php - %currentWorkingDirectory%/src/Bundle/Routing/ResourceLoader.php - %currentWorkingDirectory%/src/Bundle/Routing/Configuration.php @@ -73,6 +74,7 @@ parameters: - '/Method Sylius\\Component\\Resource\\Model\\TimestampableInterface\:\:setCreatedAt\(\) has no return type specified\./' - '/Method Sylius\\Component\\Resource\\Model\\TimestampableInterface\:\:setUpdatedAt\(\) has no return type specified\./' - '/Method Sylius\\Component\\Resource\\Repository\\InMemoryRepository\:\:findAll\(\) should return array\ but returns array\./' + - '/Method Sylius\\Component\\Resource\\Symfony\\EventDispatcher\\GenericEvent\:\:stop\(\) has no return type specified./' - '/Method Sylius\\Bundle\\ResourceBundle\\Form\\Extension\\HttpFoundation\\HttpFoundationRequestHandler::handleRequest\(\) has no return type specified./' - '/Method Sylius\\Bundle\\ResourceBundle\\Grid\\Controller\\ResourcesResolver::getResources\(\) has no return type specified./' - '/Method Sylius\\Component\\Resource\\Model\\ResourceInterface::getId\(\) has no return type specified./' diff --git a/psalm.xml b/psalm.xml index 60a7274ba..926a6e2ad 100644 --- a/psalm.xml +++ b/psalm.xml @@ -179,6 +179,12 @@ + + + + + + diff --git a/src/Bundle/.gitignore b/src/Bundle/.gitignore index 5445135dd..823f6baa1 100644 --- a/src/Bundle/.gitignore +++ b/src/Bundle/.gitignore @@ -3,3 +3,4 @@ /test/app/logs /test/config/db.sql /test/var +/test/vendor/ diff --git a/src/Bundle/Event/ResourceControllerEvent.php b/src/Bundle/Event/ResourceControllerEvent.php index 2e77a036f..7d967c47e 100644 --- a/src/Bundle/Event/ResourceControllerEvent.php +++ b/src/Bundle/Event/ResourceControllerEvent.php @@ -13,103 +13,10 @@ namespace Sylius\Bundle\ResourceBundle\Event; -use Symfony\Component\EventDispatcher\GenericEvent; -use Symfony\Component\HttpFoundation\Response; +\class_exists(\Sylius\Component\Resource\Symfony\EventDispatcher\GenericEvent::class); -class ResourceControllerEvent extends GenericEvent -{ - public const TYPE_ERROR = 'error'; - - public const TYPE_WARNING = 'warning'; - - public const TYPE_INFO = 'info'; - - public const TYPE_SUCCESS = 'success'; - - private string $messageType = ''; - - private string $message = ''; - - /** @var array */ - private $messageParameters = []; - - private int $errorCode = 500; - - private ?Response $response = null; - - /** - * @psalm-suppress MissingReturnType - */ - public function stop(string $message, string $type = self::TYPE_ERROR, array $parameters = [], int $errorCode = 500) - { - $this->messageType = $type; - $this->message = $message; - $this->messageParameters = $parameters; - $this->errorCode = $errorCode; - - $this->stopPropagation(); - } - - public function isStopped(): bool - { - return $this->isPropagationStopped(); - } - - public function getMessageType(): string - { - return $this->messageType; - } - - /** - * @param string $messageType Should be one of ResourceEvent's TYPE constants - */ - public function setMessageType($messageType): void - { - $this->messageType = $messageType; - } - - public function getMessage(): string - { - return $this->message; - } - - public function setMessage(string $message): void - { - $this->message = $message; - } - - public function getMessageParameters(): array - { - return $this->messageParameters; - } - - public function setMessageParameters(array $messageParameters): void - { - $this->messageParameters = $messageParameters; - } - - public function getErrorCode(): int - { - return $this->errorCode; - } - - public function setErrorCode(int $errorCode): void - { - $this->errorCode = $errorCode; - } - - public function setResponse(Response $response): void - { - $this->response = $response; - } - - public function hasResponse(): bool - { - return null !== $this->response; - } - - public function getResponse(): ?Response +if (false) { + class ResourceControllerEvent extends \Sylius\Component\Resource\Symfony\EventDispatcher\GenericEvent { - return $this->response; } } diff --git a/src/Bundle/Resources/config/services.xml b/src/Bundle/Resources/config/services.xml index 0f0c4962a..752db6e81 100644 --- a/src/Bundle/Resources/config/services.xml +++ b/src/Bundle/Resources/config/services.xml @@ -16,6 +16,7 @@ + diff --git a/src/Bundle/Resources/config/services/controller.xml b/src/Bundle/Resources/config/services/controller.xml index 78e94cf3e..565aff7ac 100644 --- a/src/Bundle/Resources/config/services/controller.xml +++ b/src/Bundle/Resources/config/services/controller.xml @@ -83,5 +83,11 @@ + + + + + + diff --git a/src/Bundle/Resources/config/services/dispatcher.xml b/src/Bundle/Resources/config/services/dispatcher.xml new file mode 100644 index 000000000..32d5d0e5e --- /dev/null +++ b/src/Bundle/Resources/config/services/dispatcher.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/listener.xml b/src/Bundle/Resources/config/services/listener.xml index 62ed1dd64..16c86ece7 100644 --- a/src/Bundle/Resources/config/services/listener.xml +++ b/src/Bundle/Resources/config/services/listener.xml @@ -38,21 +38,21 @@ - + - + - - - - - - + + + + + + @@ -67,12 +67,12 @@ - - - - - - + + + + + + diff --git a/src/Bundle/Resources/config/services/metadata.xml b/src/Bundle/Resources/config/services/metadata.xml index 7b93e1444..45f1020e3 100644 --- a/src/Bundle/Resources/config/services/metadata.xml +++ b/src/Bundle/Resources/config/services/metadata.xml @@ -36,6 +36,13 @@ + + + + diff --git a/src/Bundle/Resources/config/services/routing.xml b/src/Bundle/Resources/config/services/routing.xml index 815a4f96d..4927a652d 100644 --- a/src/Bundle/Resources/config/services/routing.xml +++ b/src/Bundle/Resources/config/services/routing.xml @@ -40,6 +40,7 @@ + @@ -63,6 +64,8 @@ + + diff --git a/src/Bundle/Resources/config/services/state.xml b/src/Bundle/Resources/config/services/state.xml index 01a3d73a6..d93462ca9 100644 --- a/src/Bundle/Resources/config/services/state.xml +++ b/src/Bundle/Resources/config/services/state.xml @@ -18,11 +18,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bundle/Routing/OperationRouteFactory.php b/src/Bundle/Routing/OperationRouteFactory.php index aaa171944..f38eaab23 100644 --- a/src/Bundle/Routing/OperationRouteFactory.php +++ b/src/Bundle/Routing/OperationRouteFactory.php @@ -14,7 +14,7 @@ namespace Sylius\Bundle\ResourceBundle\Routing; use Gedmo\Sluggable\Util\Urlizer; -use Sylius\Component\Resource\Action\PlaceHolderAction; +use Sylius\Component\Resource\Action\ResourceAction; use Sylius\Component\Resource\Metadata\BulkOperationInterface; use Sylius\Component\Resource\Metadata\CollectionOperationInterface; use Sylius\Component\Resource\Metadata\CreateOperationInterface; @@ -39,7 +39,7 @@ public function create(MetadataInterface $metadata, Resource $resource, HttpOper return new Route( path: $routePath, defaults: [ - '_controller' => PlaceHolderAction::class, + '_controller' => ResourceAction::class, '_sylius' => $this->getSyliusOptions($resource, $operation), ], methods: $operation->getMethods() ?? [], diff --git a/src/Bundle/test/src/Subscription/EventSubscriber/SmokeSubscriptionEventsSubscriber.php b/src/Bundle/test/src/Subscription/EventSubscriber/SmokeSubscriptionEventsSubscriber.php new file mode 100644 index 000000000..2cf6873f8 --- /dev/null +++ b/src/Bundle/test/src/Subscription/EventSubscriber/SmokeSubscriptionEventsSubscriber.php @@ -0,0 +1,58 @@ + 'smokeShowEvent', + 'app.subscription.index' => 'smokeIndexEvent', + 'app.subscription.pre_create' => 'smokePreEvent', + 'app.subscription.post_create' => 'smokePostEvent', + 'app.subscription.pre_update' => 'smokePreEvent', + 'app.subscription.post_update' => 'smokePostEvent', + 'app.subscription.pre_delete' => 'smokePreEvent', + 'app.subscription.post_delete' => 'smokePostEvent', + 'app.subscription.bulk_delete' => 'smokeBulkEvent', + ]; + } + + public function smokeShowEvent(SymfonyGenericEvent $event): void + { + } + + public function smokeIndexEvent(ResourceControllerEvent $event): void + { + } + + public function smokePreEvent(GenericEvent $event): void + { + } + + public function smokePostEvent(OperationEvent $event): void + { + } + + public function smokeBulkEvent(OperationEvent $event): void + { + } +} diff --git a/src/Component/Action/ResourceAction.php b/src/Component/Action/ResourceAction.php new file mode 100644 index 000000000..c455e43c6 --- /dev/null +++ b/src/Component/Action/ResourceAction.php @@ -0,0 +1,40 @@ +operationInitiator->initializeOperation($request); + Assert::notNull($operation); + + $context = $this->requestContextInitiator->initializeContext($request); + + return $this->processor->process($data, $operation, $context); + } +} diff --git a/src/Component/Doctrine/Common/State/RemoveProcessor.php b/src/Component/Doctrine/Common/State/RemoveProcessor.php index 0e4bd3d9d..5d2d4b416 100644 --- a/src/Component/Doctrine/Common/State/RemoveProcessor.php +++ b/src/Component/Doctrine/Common/State/RemoveProcessor.php @@ -30,17 +30,13 @@ public function __construct(private ManagerRegistry $managerRegistry) public function process(mixed $data, Operation $operation, Context $context): mixed { - $data = \is_array($data) ? $data : [$data]; - - foreach ($data as $row) { - if (!\is_object($row) || !$manager = $this->getManager($row)) { - return null; - } - - $manager->remove($row); - $manager->flush(); + if (!\is_object($data) || !$manager = $this->getManager($data)) { + return null; } + $manager->remove($data); + $manager->flush(); + return null; } diff --git a/src/Component/Metadata/Api/Delete.php b/src/Component/Metadata/Api/Delete.php index ddc656c69..4784a7091 100644 --- a/src/Component/Metadata/Api/Delete.php +++ b/src/Component/Metadata/Api/Delete.php @@ -43,6 +43,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -67,6 +68,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Api/Get.php b/src/Component/Metadata/Api/Get.php index ace64bb4a..958476150 100644 --- a/src/Component/Metadata/Api/Get.php +++ b/src/Component/Metadata/Api/Get.php @@ -44,6 +44,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -69,6 +70,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Api/GetCollection.php b/src/Component/Metadata/Api/GetCollection.php index f895a0c91..ddda909c6 100644 --- a/src/Component/Metadata/Api/GetCollection.php +++ b/src/Component/Metadata/Api/GetCollection.php @@ -44,6 +44,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -69,6 +70,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Api/Patch.php b/src/Component/Metadata/Api/Patch.php index dde4a4727..640961133 100644 --- a/src/Component/Metadata/Api/Patch.php +++ b/src/Component/Metadata/Api/Patch.php @@ -44,6 +44,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -69,6 +70,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Api/Post.php b/src/Component/Metadata/Api/Post.php index a65abbe60..584227052 100644 --- a/src/Component/Metadata/Api/Post.php +++ b/src/Component/Metadata/Api/Post.php @@ -44,6 +44,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -69,6 +70,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Api/Put.php b/src/Component/Metadata/Api/Put.php index 4ffda7b0e..e9b6ca5bb 100644 --- a/src/Component/Metadata/Api/Put.php +++ b/src/Component/Metadata/Api/Put.php @@ -44,6 +44,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -69,6 +70,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/BulkDelete.php b/src/Component/Metadata/BulkDelete.php index f0e75ae39..3de7bf8d5 100644 --- a/src/Component/Metadata/BulkDelete.php +++ b/src/Component/Metadata/BulkDelete.php @@ -35,6 +35,7 @@ public function __construct( ?bool $write = null, ?string $formType = null, ?array $formOptions = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, ) { @@ -54,6 +55,7 @@ public function __construct( write: $write, formType: $formType, formOptions: $formOptions, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, ); diff --git a/src/Component/Metadata/Create.php b/src/Component/Metadata/Create.php index a2b41f1ca..119b73b58 100644 --- a/src/Component/Metadata/Create.php +++ b/src/Component/Metadata/Create.php @@ -40,6 +40,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, private ?string $stateMachineComponent = null, @@ -67,6 +68,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, ); diff --git a/src/Component/Metadata/Delete.php b/src/Component/Metadata/Delete.php index 780aa4dd0..76874382c 100644 --- a/src/Component/Metadata/Delete.php +++ b/src/Component/Metadata/Delete.php @@ -39,6 +39,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, ) { @@ -62,6 +63,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, ); diff --git a/src/Component/Metadata/HttpOperation.php b/src/Component/Metadata/HttpOperation.php index 350675742..a0ac45ffd 100644 --- a/src/Component/Metadata/HttpOperation.php +++ b/src/Component/Metadata/HttpOperation.php @@ -42,6 +42,7 @@ public function __construct( ?array $normalizationContext = null, ?array $denormalizationContext = null, ?array $validationContext = null, + ?string $eventShortName = null, protected ?string $redirectToRoute = null, protected ?array $redirectArguments = null, ) { @@ -65,6 +66,7 @@ public function __construct( normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, validationContext: $validationContext, + eventShortName: $eventShortName, ); } diff --git a/src/Component/Metadata/Index.php b/src/Component/Metadata/Index.php index 32cd985d2..ddeb3b602 100644 --- a/src/Component/Metadata/Index.php +++ b/src/Component/Metadata/Index.php @@ -39,6 +39,7 @@ public function __construct( ?bool $serialize = null, ?string $formType = null, ?array $formOptions = null, + ?string $eventShortName = null, ?array $validationContext = null, ?string $redirectToRoute = null, ) { @@ -63,6 +64,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Operation.php b/src/Component/Metadata/Operation.php index 897d02117..3ae8850f0 100644 --- a/src/Component/Metadata/Operation.php +++ b/src/Component/Metadata/Operation.php @@ -54,6 +54,7 @@ public function __construct( protected ?array $normalizationContext = null, protected ?array $denormalizationContext = null, protected ?array $validationContext = null, + protected ?string $eventShortName = null, ) { $this->provider = $provider; $this->processor = $processor; @@ -320,4 +321,17 @@ public function withValidationContext(?array $validationContext): self return $self; } + + public function getEventShortName(): ?string + { + return $this->eventShortName; + } + + public function withEventShortName(string $eventShortName): self + { + $self = clone $this; + $self->eventShortName = $eventShortName; + + return $self; + } } diff --git a/src/Component/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php b/src/Component/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php new file mode 100644 index 000000000..64345baa7 --- /dev/null +++ b/src/Component/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php @@ -0,0 +1,70 @@ +decorated->create($resourceClass); + + /** @var ResourceMetadata $resource */ + foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + $operations = $resource->getOperations() ?? new Operations(); + + /** @var Operation $operation */ + foreach ($operations as $operation) { + /** @var string $key */ + $key = $operation->getName(); + + $operations->add($key, $this->addDefaults($resource, $operation)); + } + + $resource = $resource->withOperations($operations); + + $resourceCollectionMetadata[$i] = $resource; + } + + return $resourceCollectionMetadata; + } + + private function addDefaults(ResourceMetadata $resource, Operation $operation): Operation + { + if (null === $operation->getEventShortName()) { + $shortName = $operation instanceof ApplyStateMachineTransition ? ResourceActions::UPDATE : $operation->getShortName() ?? ''; + + $bulkPrefix = 'bulk_'; + + if (\str_starts_with($shortName, $bulkPrefix)) { + $shortName = substr($shortName, strlen($bulkPrefix)); + } + + $operation = $operation->withEventShortName($shortName); + } + + return $operation; + } +} diff --git a/src/Component/Metadata/Show.php b/src/Component/Metadata/Show.php index aebdb27ad..ea74af822 100644 --- a/src/Component/Metadata/Show.php +++ b/src/Component/Metadata/Show.php @@ -40,6 +40,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ) { parent::__construct( @@ -63,6 +64,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/Metadata/Update.php b/src/Component/Metadata/Update.php index 70a97a696..18a80acbf 100644 --- a/src/Component/Metadata/Update.php +++ b/src/Component/Metadata/Update.php @@ -40,6 +40,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?array $validationContext = null, + ?string $eventShortName = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, private ?string $stateMachineComponent = null, @@ -67,6 +68,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, validationContext: $validationContext, + eventShortName: $eventShortName, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, ); diff --git a/src/Component/State/BulkAwareProcessor.php b/src/Component/State/BulkAwareProcessor.php new file mode 100644 index 000000000..3bae750e6 --- /dev/null +++ b/src/Component/State/BulkAwareProcessor.php @@ -0,0 +1,48 @@ +decorated->process($data, $operation, $context); + } + + foreach ($data as $item) { + $this->decorated->process($item, $operation, $context); + } + + return null; + } +} diff --git a/src/Component/State/EventDispatcherBulkAwareProcessor.php b/src/Component/State/EventDispatcherBulkAwareProcessor.php new file mode 100644 index 000000000..007684d9b --- /dev/null +++ b/src/Component/State/EventDispatcherBulkAwareProcessor.php @@ -0,0 +1,43 @@ +operationEventDispatcher->dispatchBulkEvent($data, $operation, $context); + } + + return $this->decorated->process($data, $operation, $context); + } +} diff --git a/src/Component/State/EventDispatcherProcessor.php b/src/Component/State/EventDispatcherProcessor.php new file mode 100644 index 000000000..d8a6f23a2 --- /dev/null +++ b/src/Component/State/EventDispatcherProcessor.php @@ -0,0 +1,69 @@ +operationEventDispatcher->dispatchPreEvent($data, $operation, $context); + + if ($operationEvent->isStopped()) { + $eventResponse = $this->eventHandler->handleEvent( + $operationEvent, + $context, + $operation instanceof CreateOperationInterface ? ResourceActions::INDEX : null, + ); + + if (null !== $eventResponse) { + return $eventResponse; + } + } + + $result = $this->decorated->process($data, $operation, $context); + + $operationEvent = $this->operationEventDispatcher->dispatchPostEvent($data, $operation, $context); + + $eventResponse = $this->eventHandler->handleEvent( + $operationEvent, + $context, + ); + + if (null !== $eventResponse) { + return $eventResponse; + } + + return $result; + } +} diff --git a/src/Component/State/EventDispatcherProvider.php b/src/Component/State/EventDispatcherProvider.php new file mode 100644 index 000000000..e2d03a50b --- /dev/null +++ b/src/Component/State/EventDispatcherProvider.php @@ -0,0 +1,44 @@ +operationEventDispatcher->dispatch(null, $operation, $context); + } + + return $this->decorated->provide($operation, $context); + } +} diff --git a/src/Component/State/Processor.php b/src/Component/State/Processor.php index c09053b67..97faf7fef 100644 --- a/src/Component/State/Processor.php +++ b/src/Component/State/Processor.php @@ -46,6 +46,7 @@ public function process(mixed $data, Operation $operation, Context $context): mi throw new \RuntimeException(sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName() ?? '')); } + /** @var ProcessorInterface $processorInstance */ $processorInstance = $this->locator->get($processor); Assert::isInstanceOf($processorInstance, ProcessorInterface::class); diff --git a/src/Component/State/Provider.php b/src/Component/State/Provider.php index 6bfc8140b..ba8712dbe 100644 --- a/src/Component/State/Provider.php +++ b/src/Component/State/Provider.php @@ -23,8 +23,9 @@ */ final class Provider implements ProviderInterface { - public function __construct(private ContainerInterface $locator) - { + public function __construct( + private ContainerInterface $locator, + ) { } public function provide(Operation $operation, Context $context): object|iterable|null diff --git a/src/Component/State/RespondProcessor.php b/src/Component/State/RespondProcessor.php new file mode 100644 index 000000000..0c1269706 --- /dev/null +++ b/src/Component/State/RespondProcessor.php @@ -0,0 +1,37 @@ +decorated->process($data, $operation, $context); + + if ($newData instanceof Response) { + return $newData; + } + + $response = $this->responder->respond($data, $operation, $context); + Assert::isInstanceOf($response, Response::class); + + return $response; + } +} diff --git a/src/Component/State/WriteProcessor.php b/src/Component/State/WriteProcessor.php new file mode 100644 index 000000000..08e084887 --- /dev/null +++ b/src/Component/State/WriteProcessor.php @@ -0,0 +1,62 @@ +get(RequestOption::class)?->request(); + + if ( + null === $request || + !($operation->canWrite() ?? true) || + $request->isMethodSafe() || + !$request->attributes->getBoolean('is_valid', true) + ) { + return $data; + } + + switch ($request->getMethod()) { + case 'PUT': + case 'PATCH': + case 'POST': + $persistResult = $this->decorated->process($data, $operation, $context); + + if (!$persistResult) { + return $data; + } + + return $persistResult; + + break; + case 'DELETE': + $this->decorated->process($data, $operation, $context); + + return $data; + } + + return $data; + } +} diff --git a/src/Component/Symfony/EventDispatcher/GenericEvent.php b/src/Component/Symfony/EventDispatcher/GenericEvent.php new file mode 100644 index 000000000..3144caf85 --- /dev/null +++ b/src/Component/Symfony/EventDispatcher/GenericEvent.php @@ -0,0 +1,117 @@ +messageType = $type; + $this->message = $message; + $this->messageParameters = $parameters; + $this->errorCode = $errorCode; + + $this->stopPropagation(); + } + + public function isStopped(): bool + { + return $this->isPropagationStopped(); + } + + public function getMessageType(): string + { + return $this->messageType; + } + + /** + * @param string $messageType Should be one of ResourceEvent's TYPE constants + */ + public function setMessageType($messageType): void + { + $this->messageType = $messageType; + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getMessageParameters(): array + { + return $this->messageParameters; + } + + public function setMessageParameters(array $messageParameters): void + { + $this->messageParameters = $messageParameters; + } + + public function getErrorCode(): int + { + return $this->errorCode; + } + + public function setErrorCode(int $errorCode): void + { + $this->errorCode = $errorCode; + } + + public function setResponse(Response $response): void + { + $this->response = $response; + } + + public function hasResponse(): bool + { + return null !== $this->response; + } + + public function getResponse(): ?Response + { + return $this->response; + } +} + +\class_alias(GenericEvent::class, \Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent::class); diff --git a/src/Component/Symfony/EventDispatcher/OperationEvent.php b/src/Component/Symfony/EventDispatcher/OperationEvent.php new file mode 100644 index 000000000..5dd725865 --- /dev/null +++ b/src/Component/Symfony/EventDispatcher/OperationEvent.php @@ -0,0 +1,36 @@ +getArgument('operation'); + + return $operation; + } + + public function getContext(): Context + { + /** @var Context $context */ + $context = $this->getArgument('context'); + + return $context; + } +} diff --git a/src/Component/Symfony/EventDispatcher/OperationEventDispatcher.php b/src/Component/Symfony/EventDispatcher/OperationEventDispatcher.php new file mode 100644 index 000000000..8098307f0 --- /dev/null +++ b/src/Component/Symfony/EventDispatcher/OperationEventDispatcher.php @@ -0,0 +1,74 @@ +dispatchEvent($data, $operation, $context); + } + + public function dispatchBulkEvent(mixed $data, Operation $operation, Context $context): OperationEvent + { + return $this->dispatchEvent($data, $operation, $context, 'bulk'); + } + + public function dispatchPreEvent(mixed $data, Operation $operation, Context $context): OperationEvent + { + return $this->dispatchEvent($data, $operation, $context, 'pre'); + } + + public function dispatchPostEvent(mixed $data, Operation $operation, Context $context): OperationEvent + { + return $this->dispatchEvent($data, $operation, $context, 'post'); + } + + public function dispatchInitializeEvent(mixed $data, Operation $operation, Context $context): OperationEvent + { + return $this->dispatchEvent($data, $operation, $context, 'initialize'); + } + + private function dispatchEvent(mixed $data, Operation $operation, Context $context, ?string $eventType = null): OperationEvent + { + $operationEvent = new OperationEvent($data, ['operation' => $operation, 'context' => $context]); + + $resource = $operation->getResource(); + + if (null === $resource) { + return $operationEvent; + } + + $eventName = sprintf( + '%s.%s.%s%s', + $resource->getApplicationName() ?? '', + $resource->getName() ?? '', + $eventType ? $eventType . '_' : '', + $operation->getEventShortName() ?? '', + ); + + $this->eventDispatcher->dispatch($operationEvent, $eventName); + + return $operationEvent; + } +} diff --git a/src/Component/Symfony/EventDispatcher/OperationEventDispatcherInterface.php b/src/Component/Symfony/EventDispatcher/OperationEventDispatcherInterface.php new file mode 100644 index 000000000..43b21a780 --- /dev/null +++ b/src/Component/Symfony/EventDispatcher/OperationEventDispatcherInterface.php @@ -0,0 +1,30 @@ +get(RequestOption::class)?->request(); + + if ( + 'html' === $request?->getRequestFormat() && + null !== $operationEventResponse = $event->getResponse() + ) { + return $operationEventResponse; + } + + if (!$event->isStopped()) { + return null; + } + + if ('html' !== $request?->getRequestFormat()) { + throw new HttpException($event->getErrorCode(), $event->getMessage()); + } + + $operation = $event->getOperation(); + + if ($operation instanceof HttpOperation && null !== $request) { + if (null === $newOperation) { + return $this->redirectHandler->redirectToResource($event->getSubject(), $operation, $request); + } + + return $this->redirectHandler->redirectToOperation($event->getSubject(), $operation, $request, $newOperation); + } + + return null; + } +} diff --git a/src/Component/Symfony/EventDispatcher/OperationEventHandlerInterface.php b/src/Component/Symfony/EventDispatcher/OperationEventHandlerInterface.php new file mode 100644 index 000000000..00d7c9694 --- /dev/null +++ b/src/Component/Symfony/EventDispatcher/OperationEventHandlerInterface.php @@ -0,0 +1,29 @@ +getControllerResult(); $request = $event->getRequest(); + $controllerResult = $request->attributes->get('data'); $context = $this->contextInitiator->initializeContext($request); $operation = $this->operationInitiator->initializeOperation($request); @@ -41,7 +42,6 @@ public function onKernelView(ViewEvent $event): void $format = $request->getRequestFormat(); if ( - $controllerResult instanceof Response || !($operation instanceof CreateOperationInterface || $operation instanceof UpdateOperationInterface) || 'html' !== $format || null == $operation->getFormType() diff --git a/src/Component/Symfony/EventListener/ValidateListener.php b/src/Component/Symfony/EventListener/ValidateListener.php index f4de1cd9a..d16f27eb8 100644 --- a/src/Component/Symfony/EventListener/ValidateListener.php +++ b/src/Component/Symfony/EventListener/ValidateListener.php @@ -19,6 +19,7 @@ use Sylius\Component\Resource\Symfony\Validator\Exception\ValidationException; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ViewEvent; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -30,10 +31,10 @@ public function __construct( ) { } - public function onKernelView(ViewEvent $event): void + public function onKernelRequest(RequestEvent $event): void { - $controllerResult = $event->getControllerResult(); $request = $event->getRequest(); + $controllerResult = $request->attributes->get('data'); /** @var FormInterface|null $form */ $form = $request->attributes->get('form'); @@ -72,7 +73,7 @@ public function onKernelView(ViewEvent $event): void $form->isValid() ) { $request->attributes->set('is_valid', true); - $event->setControllerResult($form->getData()); + $request->attributes->set('data', $form->getData()); return; } diff --git a/src/Component/Symfony/EventListener/WriteListener.php b/src/Component/Symfony/EventListener/WriteListener.php index d631afc33..a9340bdac 100644 --- a/src/Component/Symfony/EventListener/WriteListener.php +++ b/src/Component/Symfony/EventListener/WriteListener.php @@ -16,6 +16,7 @@ use Sylius\Component\Resource\Context\Initiator\RequestContextInitiatorInterface; use Sylius\Component\Resource\Metadata\Operation\HttpOperationInitiatorInterface; use Sylius\Component\Resource\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ViewEvent; final class WriteListener @@ -50,11 +51,19 @@ public function onKernelView(ViewEvent $event): void case 'POST': $persistResult = $this->processor->process($controllerResult, $operation, $context); - if ($persistResult) { - $controllerResult = $persistResult; - $event->setControllerResult($controllerResult); + if (!$persistResult) { + return; } + if ($persistResult instanceof Response) { + $event->setResponse($persistResult); + + return; + } + + $controllerResult = $persistResult; + $event->setControllerResult($controllerResult); + break; case 'DELETE': $this->processor->process($controllerResult, $operation, $context); diff --git a/src/Component/Symfony/Request/State/TwigResponder.php b/src/Component/Symfony/Request/State/TwigResponder.php index 50414b941..a3cefda70 100644 --- a/src/Component/Symfony/Request/State/TwigResponder.php +++ b/src/Component/Symfony/Request/State/TwigResponder.php @@ -21,7 +21,7 @@ use Sylius\Component\Resource\Metadata\Operation; use Sylius\Component\Resource\Metadata\UpdateOperationInterface; use Sylius\Component\Resource\State\ResponderInterface; -use Sylius\Component\Resource\Symfony\Routing\RedirectHandler; +use Sylius\Component\Resource\Symfony\Routing\RedirectHandlerInterface; use Sylius\Component\Resource\Twig\Context\Factory\ContextFactoryInterface; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; @@ -29,7 +29,7 @@ final class TwigResponder implements ResponderInterface { public function __construct( - private RedirectHandler $redirectHandler, + private RedirectHandlerInterface $redirectHandler, private ContextFactoryInterface $contextFactory, private ?Environment $twig, ) { diff --git a/src/Component/Symfony/Routing/Factory/OperationRouteNameFactory.php b/src/Component/Symfony/Routing/Factory/OperationRouteNameFactory.php index cffd392e2..efd8112be 100644 --- a/src/Component/Symfony/Routing/Factory/OperationRouteNameFactory.php +++ b/src/Component/Symfony/Routing/Factory/OperationRouteNameFactory.php @@ -15,7 +15,7 @@ use Sylius\Component\Resource\Metadata\Operation; -final class OperationRouteNameFactory +final class OperationRouteNameFactory implements OperationRouteNameFactoryInterface { public function createRouteName(Operation $operation, ?string $shortName = null): string { diff --git a/src/Component/Symfony/Routing/Factory/OperationRouteNameFactoryInterface.php b/src/Component/Symfony/Routing/Factory/OperationRouteNameFactoryInterface.php new file mode 100644 index 000000000..0ace6ceeb --- /dev/null +++ b/src/Component/Symfony/Routing/Factory/OperationRouteNameFactoryInterface.php @@ -0,0 +1,21 @@ +getName() ?? '')); } + $parameters = $this->getRouteArguments($data, $operation, $request); + + return $this->redirectToRoute($data, $route, $parameters); + } + + public function redirectToOperation(mixed $data, HttpOperation $operation, Request $request, string $newOperation): RedirectResponse + { + $route = $this->operationRouteNameFactory->createRouteName($operation, $newOperation); + + $parameters = $this->getRouteArguments($data, $operation, $request); + + return $this->redirectToRoute($data, $route, $parameters); + } + + public function redirectToRoute(mixed $data, string $route, array $parameters = []): RedirectResponse + { + return new RedirectResponse($this->router->generate($route, $parameters)); + } + + private function getRouteArguments(mixed $data, HttpOperation $operation, Request $request): array + { $resource = $operation->getResource(); if (null === $resource) { @@ -51,14 +74,7 @@ public function redirectToResource(mixed $data, HttpOperation $operation, Reques $redirectArguments[$identifier] = 'resource.' . $identifier; } - $parameters = $this->parseResourceValues($resource, $redirectArguments, $data); - - return $this->redirectToRoute($data, $route, $parameters); - } - - public function redirectToRoute(mixed $data, string $route, array $parameters = []): RedirectResponse - { - return new RedirectResponse($this->router->generate($route, $parameters)); + return $this->parseResourceValues($resource, $redirectArguments, $data); } private function parseResourceValues(Resource $resource, array $parameters, mixed $data): array diff --git a/src/Component/Symfony/Routing/RedirectHandlerInterface.php b/src/Component/Symfony/Routing/RedirectHandlerInterface.php new file mode 100644 index 000000000..a698e1c64 --- /dev/null +++ b/src/Component/Symfony/Routing/RedirectHandlerInterface.php @@ -0,0 +1,30 @@ +beConstructedWith($decorated); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(EventShortNameResourceMetadataCollectionFactory::class); + } + + function it_configures_default_event_short_name_on_operations( + ResourceMetadataCollectionFactoryInterface $decorated, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + + $create = (new Create(name: 'app_book_create'))->withResource($resource); + $show = (new Show(name: 'app_book_show'))->withResource($resource); + $applyStateMachineTransition = (new ApplyStateMachineTransition(name: 'app_book_publish'))->withResource($resource); + $bulkDelete = (new BulkDelete(name: 'app_book_bulk_delete'))->withResource($resource); + + $resource = $resource->withOperations(new Operations([ + $create->getName() => $create, + $show->getName() => $show, + $applyStateMachineTransition->getName() => $applyStateMachineTransition, + $bulkDelete->getName() => $bulkDelete, + ])); + + $resourceMetadataCollection = new ResourceMetadataCollection(); + $resourceMetadataCollection[] = $resource; + + $decorated->create('App\Resource')->willReturn($resourceMetadataCollection); + + $resourceMetadataCollection = $this->create('App\Resource'); + + $create = $resourceMetadataCollection->getOperation('app.book', 'app_book_create'); + $create->getEventShortName()->shouldReturn('create'); + + $show = $resourceMetadataCollection->getOperation('app.book', 'app_book_show'); + $show->getEventShortName()->shouldReturn('show'); + + $publish = $resourceMetadataCollection->getOperation('app.book', 'app_book_publish'); + $publish->getEventShortName()->shouldReturn('update'); + + $bulkDelete = $resourceMetadataCollection->getOperation('app.book', 'app_book_bulk_delete'); + $bulkDelete->getEventShortName()->shouldReturn('delete'); + } +} diff --git a/src/Component/spec/State/BulkAwareProcessorSpec.php b/src/Component/spec/State/BulkAwareProcessorSpec.php new file mode 100644 index 000000000..cebf7668e --- /dev/null +++ b/src/Component/spec/State/BulkAwareProcessorSpec.php @@ -0,0 +1,67 @@ +beConstructedWith($decorated, $operationEventDispatcher); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(BulkAwareProcessor::class); + } + + function it_calls_decorated_processor_for_each_data_for_bulk_operation( + ProcessorInterface $decorated, + \stdClass $firstItem, + \stdClass $secondItem, + ): void { + $operation = new BulkDelete(); + $context = new Context(); + + $data = [ + $firstItem->getWrappedObject(), + $secondItem->getWrappedObject(), + ]; + + $decorated->process($firstItem, $operation, $context)->shouldBeCalled(); + $decorated->process($secondItem, $operation, $context)->shouldBeCalled(); + + $this->process($data, $operation, $context)->shouldReturn(null); + } + + function it_calls_decorated_processor_for_data_for_other_operation_than_bulk_one( + ProcessorInterface $decorated, + \stdClass $data, + \stdClass $result, + ): void { + $operation = new Delete(); + $context = new Context(); + + $decorated->process($data, $operation, $context)->willReturn($result)->shouldBeCalled(); + + $this->process($data, $operation, $context)->shouldReturn($result); + } +} diff --git a/src/Component/spec/State/EventDispatcherBulkAwareProcessorSpec.php b/src/Component/spec/State/EventDispatcherBulkAwareProcessorSpec.php new file mode 100644 index 000000000..d4aff8830 --- /dev/null +++ b/src/Component/spec/State/EventDispatcherBulkAwareProcessorSpec.php @@ -0,0 +1,54 @@ +beConstructedWith($decorated, $operationEventDispatcher); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(EventDispatcherBulkAwareProcessor::class); + } + + function it_dispatches_events_for_bulk_operation( + ProcessorInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + ): void { + $operation = new BulkDelete(processor: [ProcessorWithCallable::class, 'process']); + $context = new Context(); + + $operationEvent = new OperationEvent(); + + $data = []; + + $operationEventDispatcher->dispatchBulkEvent($data, $operation, $context)->willReturn($operationEvent)->shouldBeCalled(); + + $decorated->process($data, $operation, $context)->shouldBeCalled(); + + $this->process($data, $operation, $context); + } +} diff --git a/src/Component/spec/State/EventDispatcherProcessorSpec.php b/src/Component/spec/State/EventDispatcherProcessorSpec.php new file mode 100644 index 000000000..fdee5acc0 --- /dev/null +++ b/src/Component/spec/State/EventDispatcherProcessorSpec.php @@ -0,0 +1,111 @@ +beConstructedWith($decorated, $operationEventDispatcher, $eventHandler); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(EventDispatcherProcessor::class); + } + + function it_dispatches_pre_and_post_events_with_operation_as_string( + ProcessorInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + OperationEventHandlerInterface $eventHandler, + \stdClass $data, + \stdClass $result, + ): void { + $operation = new Create(processor: '\App\Processor'); + $context = new Context(); + + $decorated->process($data, $operation, $context)->willReturn($result)->shouldBeCalled(); + + $preEvent = new OperationEvent(); + $postEvent = new OperationEvent(); + + $operationEventDispatcher->dispatchPreEvent($data, $operation, $context)->willReturn($preEvent)->shouldBeCalled(); + $operationEventDispatcher->dispatchPostEvent($data, $operation, $context)->willReturn($postEvent)->shouldBeCalled(); + + $eventHandler->handleEvent($preEvent, $context, 'index')->willReturn(null)->shouldNotBeCalled(); + $eventHandler->handleEvent($postEvent, $context)->willReturn(null)->shouldBeCalled(); + + $this->process($data, $operation, $context)->shouldReturn($result); + } + + function it_does_not_call_processor_if_pre_event_returns_a_response_and_has_been_stopped( + ProcessorInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + OperationEventHandlerInterface $eventHandler, + \stdClass $data, + \stdClass $result, + Response $response, + ): void { + $operation = new Create(processor: '\App\Processor'); + $context = new Context(); + + $decorated->process($data, $operation, $context)->willReturn($result)->shouldNotBeCalled(); + + $preEvent = new OperationEvent(); + $preEvent->stop(message: 'What the hell is going on?', errorCode: 666); + + $operationEventDispatcher->dispatchPreEvent($data, $operation, $context)->willReturn($preEvent)->shouldBeCalled(); + + $eventHandler->handleEvent($preEvent, $context, 'index')->willReturn($response)->shouldBeCalled(); + + $this->process($data, $operation, $context)->shouldReturn($response); + } + + function it_returns_post_event_response( + ProcessorInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + OperationEventHandlerInterface $eventHandler, + \stdClass $data, + \stdClass $result, + Response $response, + ): void { + $operation = new Create(processor: '\App\Processor'); + $context = new Context(); + + $decorated->process($data, $operation, $context)->willReturn($result)->shouldBeCalled(); + + $preEvent = new OperationEvent(); + $postEvent = new OperationEvent(); + + $operationEventDispatcher->dispatchPreEvent($data, $operation, $context)->willReturn($preEvent)->shouldBeCalled(); + $operationEventDispatcher->dispatchPostEvent($data, $operation, $context)->willReturn($postEvent)->shouldBeCalled(); + + $eventHandler->handleEvent($postEvent, $context)->willReturn($response)->shouldBeCalled(); + + $this->process($data, $operation, $context)->shouldReturn($response); + } +} diff --git a/src/Component/spec/State/EventDispatcherProviderSpec.php b/src/Component/spec/State/EventDispatcherProviderSpec.php new file mode 100644 index 000000000..ac7cc8f7f --- /dev/null +++ b/src/Component/spec/State/EventDispatcherProviderSpec.php @@ -0,0 +1,85 @@ +beConstructedWith($decorated, $operationEventDispatcher); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(EventDispatcherProvider::class); + } + + function it_dispatches_events_for_index_operation( + ProviderInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + ): void { + $operation = new Index(provider: '\App\Provider'); + $context = new Context(); + + $operationEvent = new OperationEvent(); + + $decorated->provide($operation, $context)->shouldBeCalled(); + + $operationEventDispatcher->dispatch(null, $operation, $context)->willReturn($operationEvent)->shouldBeCalled(); + + $this->provide($operation, $context); + } + + function it_dispatches_events_for_show_operation( + ProviderInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + ): void { + $operation = new Show(provider: '\App\Provider'); + $context = new Context(); + + $operationEvent = new OperationEvent(); + + $decorated->provide($operation, $context)->shouldBeCalled(); + + $operationEventDispatcher->dispatch(null, $operation, $context)->willReturn($operationEvent)->shouldBeCalled(); + + $this->provide($operation, $context); + } + + function it_does_not_dispatch_events_for_create_operation( + ProviderInterface $decorated, + OperationEventDispatcherInterface $operationEventDispatcher, + ): void { + $operation = new Create(provider: '\App\Provider'); + $context = new Context(); + + $decorated->provide($operation, $context)->shouldBeCalled(); + + $operationEventDispatcher->dispatch(null, $operation, $context)->shouldNotBeCalled(); + + $this->provide($operation, $context); + } +} diff --git a/src/Component/spec/Symfony/EventDispatcher/OperationEventDispatcherSpec.php b/src/Component/spec/Symfony/EventDispatcher/OperationEventDispatcherSpec.php new file mode 100644 index 000000000..df6b8ac98 --- /dev/null +++ b/src/Component/spec/Symfony/EventDispatcher/OperationEventDispatcherSpec.php @@ -0,0 +1,132 @@ +beConstructedWith($eventDispatcher); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(OperationEventDispatcher::class); + } + + function it_dispatches_events( + EventDispatcherInterface $eventDispatcher, + \stdClass $data, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + $show = (new Show(eventShortName: 'read'))->withResource($resource); + + $context = new Context(); + + $operationEvent = new OperationEvent($data->getWrappedObject(), [ + 'operation' => $show, + 'context' => $context, + ]); + + $eventDispatcher->dispatch($operationEvent, 'app.book.read')->shouldBeCalled(); + + $this->dispatch($data, $show, $context)->shouldHaveType(OperationEvent::class); + } + + function it_dispatches_events_for_bulk_operations( + EventDispatcherInterface $eventDispatcher, + \ArrayObject $data, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + $bulkDelete = (new BulkDelete(eventShortName: 'delete'))->withResource($resource); + + $context = new Context(); + + $operationEvent = new OperationEvent($data->getWrappedObject(), [ + 'operation' => $bulkDelete, + 'context' => $context, + ]); + + $eventDispatcher->dispatch($operationEvent, 'app.book.bulk_delete')->shouldBeCalled(); + + $this->dispatchBulkEvent($data, $bulkDelete, $context)->shouldHaveType(OperationEvent::class); + } + + function it_dispatches_pre_events( + EventDispatcherInterface $eventDispatcher, + \stdClass $data, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + $create = (new Create(eventShortName: 'create'))->withResource($resource); + + $context = new Context(); + + $operationEvent = new OperationEvent($data->getWrappedObject(), [ + 'operation' => $create, + 'context' => $context, + ]); + + $eventDispatcher->dispatch($operationEvent, 'app.book.pre_create')->shouldBeCalled(); + + $this->dispatchPreEvent($data, $create, $context)->shouldHaveType(OperationEvent::class); + } + + function it_dispatches_post_events( + EventDispatcherInterface $eventDispatcher, + \stdClass $data, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + $create = (new Create(eventShortName: 'create'))->withResource($resource); + + $context = new Context(); + + $operationEvent = new OperationEvent($data->getWrappedObject(), [ + 'operation' => $create, + 'context' => $context, + ]); + + $eventDispatcher->dispatch($operationEvent, 'app.book.post_create')->shouldBeCalled(); + + $this->dispatchPostEvent($data, $create, $context)->shouldHaveType(OperationEvent::class); + } + + function it_dispatches_initialize_events( + EventDispatcherInterface $eventDispatcher, + \stdClass $data, + ): void { + $resource = new Resource(alias: 'app.book', name: 'book', applicationName: 'app'); + $create = (new Create(eventShortName: 'create'))->withResource($resource); + + $context = new Context(); + + $operationEvent = new OperationEvent($data->getWrappedObject(), [ + 'operation' => $create, + 'context' => $context, + ]); + + $eventDispatcher->dispatch($operationEvent, 'app.book.initialize_create')->shouldBeCalled(); + + $this->dispatchInitializeEvent($data, $create, $context)->shouldHaveType(OperationEvent::class); + } +} diff --git a/src/Component/spec/Symfony/EventDispatcher/OperationEventHandlerSpec.php b/src/Component/spec/Symfony/EventDispatcher/OperationEventHandlerSpec.php new file mode 100644 index 000000000..b10cf408f --- /dev/null +++ b/src/Component/spec/Symfony/EventDispatcher/OperationEventHandlerSpec.php @@ -0,0 +1,140 @@ +beConstructedWith($redirectHandler); + } + + function it_is_initializable(): void + { + $this->shouldHaveType(OperationEventHandler::class); + } + + function it_throws_an_http_exception_when_event_is_stopped_and_request_format_is_not_html(): void + { + $event = new OperationEvent(); + $event->stop(message: 'What the hell is going on?', errorCode: 666); + + $context = new Context(); + + $this->shouldThrow(new HttpException(666, 'What the hell is going on?')) + ->during('handleEvent', [$event, $context]) + ; + } + + function it_returns_response_from_event_when_it_has_one_and_request_format_is_html( + Request $request, + Response $response, + ): void { + $event = new OperationEvent(); + $event->setResponse($response->getWrappedObject()); + + $context = new Context(new RequestOption($request->getWrappedObject())); + + $request->getRequestFormat()->willReturn('html'); + + $this->handleEvent($event, $context)->shouldReturn($response); + } + + function it_does_not_returns_response_from_event_when_request_format_is_not_html( + Request $request, + Response $response, + ): void { + $event = new OperationEvent(); + $event->setResponse($response->getWrappedObject()); + + $context = new Context(new RequestOption($request->getWrappedObject())); + + $request->getRequestFormat()->willReturn('json'); + + $this->handleEvent($event, $context)->shouldReturn(null); + } + + function it_can_redirect_to_resource_when_event_is_stopped_and_has_no_response_and_operation_is_an_http_operation( + Request $request, + \stdClass $data, + RedirectHandlerInterface $redirectHandler, + RedirectResponse $response, + ): void { + $event = new OperationEvent($data); + $event->stop(message: 'What the hell is going on?', errorCode: 666); + + $operation = new Update(); + + $context = new Context(new RequestOption($request->getWrappedObject())); + + $event->setArgument('operation', $operation); + + $request->getRequestFormat()->willReturn('html'); + + $redirectHandler->redirectToResource($data, $operation, $request)->willReturn($response)->shouldBeCalled(); + + $this->handleEvent($event, $context)->shouldHaveType(RedirectResponse::class); + } + + function it_can_redirect_to_operation_when_event_is_stopped_and_has_no_response_and_operation_is_an_http_operation( + Request $request, + \stdClass $data, + RedirectHandlerInterface $redirectHandler, + RedirectResponse $response, + ): void { + $event = new OperationEvent($data); + $event->stop(message: 'What the hell is going on?', errorCode: 666); + + $operation = new Update(); + + $context = new Context(new RequestOption($request->getWrappedObject())); + + $event->setArgument('operation', $operation); + + $request->getRequestFormat()->willReturn('html'); + + $redirectHandler->redirectToOperation($data, $operation, $request, 'index')->willReturn($response)->shouldBeCalled(); + + $this->handleEvent($event, $context, 'index')->shouldHaveType(RedirectResponse::class); + } + + function it_returns_null_when_event_is_stopped_and_has_no_response_and_operation_is_not_an_http_operation( + Request $request, + \stdClass $data, + Operation $operation, + ): void { + $event = new OperationEvent($data); + $event->stop(message: 'What the hell is going on?', errorCode: 666); + $event->setArgument('operation', $operation->getWrappedObject()); + + $context = new Context(new RequestOption($request->getWrappedObject())); + + $request->getRequestFormat()->willReturn('html'); + + $this->handleEvent($event, $context)->shouldReturn(null); + } +} diff --git a/src/Component/spec/Symfony/EventListener/WriteListenerSpec.php b/src/Component/spec/Symfony/EventListener/WriteListenerSpec.php index da0d7c7b6..c7c49b9d5 100644 --- a/src/Component/spec/Symfony/EventListener/WriteListenerSpec.php +++ b/src/Component/spec/Symfony/EventListener/WriteListenerSpec.php @@ -23,6 +23,7 @@ use Sylius\Component\Resource\Symfony\EventListener\WriteListener; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ViewEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Webmozart\Assert\Assert; @@ -110,6 +111,44 @@ function it_replaces_controller_result_on_event( Assert::eq($event->getControllerResult(), 'persisted_result'); } + function it_does_not_replace_controller_result_if_it_is_a_response_already( + HttpKernelInterface $kernel, + Request $request, + HttpOperationInitiatorInterface $operationInitiator, + RequestContextInitiatorInterface $contextInitiator, + ParameterBag $attributes, + HttpOperation $operation, + ProcessorInterface $processor, + Response $response, + ): void { + $event = new ViewEvent( + $kernel->getWrappedObject(), + $request->getWrappedObject(), + HttpKernelInterface::MAIN_REQUEST, + ['foo' => 'fighters'], + ); + + $context = new Context(); + + $contextInitiator->initializeContext($request)->willReturn($context); + + $operationInitiator->initializeOperation($request)->willReturn($operation); + + $request->attributes = $attributes; + $request->getMethod()->willReturn('POST'); + $request->isMethodSafe()->willReturn(false); + + $attributes->getBoolean('is_valid', true)->willReturn(true); + + $processor->process(['foo' => 'fighters'], $operation, Argument::type(Context::class))->willReturn($response)->shouldBeCalled(); + + $this->onKernelView($event); + + $response->__toString()->willReturn('response_result'); + + Assert::eq($event->getResponse(), 'response_result'); + } + function it_removes_controller_result_on_event_with_delete_method( HttpKernelInterface $kernel, Request $request, diff --git a/src/Component/spec/Symfony/Request/State/TwigResponderSpec.php b/src/Component/spec/Symfony/Request/State/TwigResponderSpec.php index e2700a418..98cfd7cf6 100644 --- a/src/Component/spec/Symfony/Request/State/TwigResponderSpec.php +++ b/src/Component/spec/Symfony/Request/State/TwigResponderSpec.php @@ -21,21 +21,21 @@ use Sylius\Component\Resource\Metadata\Resource; use Sylius\Component\Resource\Metadata\Show; use Sylius\Component\Resource\Symfony\Request\State\TwigResponder; -use Sylius\Component\Resource\Symfony\Routing\ArgumentParser; -use Sylius\Component\Resource\Symfony\Routing\RedirectHandler; +use Sylius\Component\Resource\Symfony\Routing\RedirectHandlerInterface; use Sylius\Component\Resource\Twig\Context\Factory\ContextFactoryInterface; -use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\RouterInterface; use Twig\Environment; final class TwigResponderSpec extends ObjectBehavior { - function let(Environment $twig, RouterInterface $router, ContextFactoryInterface $contextFactory): void - { - $this->beConstructedWith(new RedirectHandler($router->getWrappedObject(), new ArgumentParser(new ExpressionLanguage())), $contextFactory, $twig); + function let( + Environment $twig, + RedirectHandlerInterface $redirectHandler, + ContextFactoryInterface $contextFactory, + ): void { + $this->beConstructedWith($redirectHandler, $contextFactory, $twig); } function it_is_initializable(): void @@ -99,20 +99,18 @@ function it_redirect_to_route_after_creation( \ArrayObject $data, Request $request, ParameterBag $attributes, - RouterInterface $router, + RedirectHandlerInterface $redirectHandler, + RedirectResponse $response, ): void { $data->id = 'xyz'; $request->attributes = $attributes; $attributes->getBoolean('is_valid', true)->willReturn(true)->shouldBeCalled(); - $resource = new Resource(alias: 'app.book', pluralName: 'books'); - $operation = (new Create(redirectToRoute: 'app_dummy_index'))->withResource($resource); + $operation = new Create(); - $router->generate('app_dummy_index', ['id' => 'xyz'])->willReturn('/dummies')->shouldBeCalled(); + $redirectHandler->redirectToResource($data, $operation, $request)->willReturn($response); - $response = $this->respond($data, $operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldHaveType(RedirectResponse::class); - $response->getTargetUrl()->shouldReturn('/dummies'); + $this->respond($data, $operation, new Context(new RequestOption($request->getWrappedObject())))->shouldReturn($response); } } diff --git a/src/Component/spec/Symfony/Routing/RedirectHandlerSpec.php b/src/Component/spec/Symfony/Routing/RedirectHandlerSpec.php index 9fb9a8c0d..d5118c5af 100644 --- a/src/Component/spec/Symfony/Routing/RedirectHandlerSpec.php +++ b/src/Component/spec/Symfony/Routing/RedirectHandlerSpec.php @@ -19,6 +19,7 @@ use Sylius\Component\Resource\Metadata\Delete; use Sylius\Component\Resource\Metadata\Resource; use Sylius\Component\Resource\Symfony\Routing\ArgumentParser; +use Sylius\Component\Resource\Symfony\Routing\Factory\OperationRouteNameFactoryInterface; use Sylius\Component\Resource\Symfony\Routing\RedirectHandler; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpFoundation\Request; @@ -26,11 +27,13 @@ final class RedirectHandlerSpec extends ObjectBehavior { - function let(RouterInterface $router): void + function let(RouterInterface $router, OperationRouteNameFactoryInterface $operationRouteNameFactory): void { - $this->beConstructedWith($router, new ArgumentParser( - new ExpressionLanguage(), - )); + $this->beConstructedWith( + $router, + new ArgumentParser(new ExpressionLanguage()), + $operationRouteNameFactory, + ); } function it_is_initializable(): void @@ -72,7 +75,7 @@ function it_redirects_to_resource_with_id_via_property_access( Request $request, RouterInterface $router, ): void { - $data = new BoardGame('uid'); + $data = new BoardGameResource('uid'); $operation = new Create(redirectToRoute: 'app_board_game_index'); $resource = new Resource(alias: 'app.board_game'); $operation = $operation->withResource($resource);