diff --git a/psalm.xml b/psalm.xml index 2defa899f..2f64f6e27 100644 --- a/psalm.xml +++ b/psalm.xml @@ -46,11 +46,20 @@ + + + + + + + + + @@ -107,6 +116,13 @@ + + + + + + + @@ -170,6 +186,13 @@ + + + + + + + @@ -311,6 +334,13 @@ + + + + + + + diff --git a/src/Bundle/DependencyInjection/Configuration.php b/src/Bundle/DependencyInjection/Configuration.php index 6de235407..7da7477d5 100644 --- a/src/Bundle/DependencyInjection/Configuration.php +++ b/src/Bundle/DependencyInjection/Configuration.php @@ -39,6 +39,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('mapping') ->addDefaultsIfNotSet() ->children() + ->arrayNode('imports') + ->prototype('scalar')->end() + ->end() ->arrayNode('paths') ->prototype('scalar')->end() ->end() diff --git a/src/Bundle/Resources/config/services/integrations/doctrine/orm.xml b/src/Bundle/Resources/config/services/integrations/doctrine/orm.xml index 0ca399cfe..5f19df807 100644 --- a/src/Bundle/Resources/config/services/integrations/doctrine/orm.xml +++ b/src/Bundle/Resources/config/services/integrations/doctrine/orm.xml @@ -20,6 +20,24 @@ + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/metadata.xml b/src/Bundle/Resources/config/services/metadata.xml index 18fea2a9b..55b54b4bd 100644 --- a/src/Bundle/Resources/config/services/metadata.xml +++ b/src/Bundle/Resources/config/services/metadata.xml @@ -12,35 +12,61 @@ --> + + + + - + + %sylius.resource.mapping% + + + + + %sylius.resource.mapping% + + + + + + + + - - - + - %sylius.state_machine_component.default% - + + + + + + %sylius.state_machine_component.default% %sylius.resource.settings% @@ -91,8 +117,8 @@ diff --git a/src/Bundle/Resources/config/services/metadata/resource_name.xml b/src/Bundle/Resources/config/services/metadata/resource_name.xml new file mode 100644 index 000000000..d528bb2dd --- /dev/null +++ b/src/Bundle/Resources/config/services/metadata/resource_name.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + %sylius.resource.mapping% + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/routing.xml b/src/Bundle/Resources/config/services/routing.xml index 567598b92..7a7c17dd5 100644 --- a/src/Bundle/Resources/config/services/routing.xml +++ b/src/Bundle/Resources/config/services/routing.xml @@ -12,6 +12,10 @@ --> + + + + @@ -102,7 +106,7 @@ - + diff --git a/src/Bundle/Resources/config/services/routing/loader.xml b/src/Bundle/Resources/config/services/routing/loader.xml new file mode 100644 index 000000000..12b09fdbd --- /dev/null +++ b/src/Bundle/Resources/config/services/routing/loader.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/routing/resource.xml b/src/Bundle/Resources/config/services/routing/resource.xml new file mode 100644 index 000000000..ac8fa84d8 --- /dev/null +++ b/src/Bundle/Resources/config/services/routing/resource.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/state.xml b/src/Bundle/Resources/config/services/state.xml index a6af7a29b..597454bc2 100644 --- a/src/Bundle/Resources/config/services/state.xml +++ b/src/Bundle/Resources/config/services/state.xml @@ -37,13 +37,6 @@ - - - - - - - @@ -71,7 +64,7 @@ - + diff --git a/src/Component/composer.json b/src/Component/composer.json index 4cc4d95f5..3375a24de 100644 --- a/src/Component/composer.json +++ b/src/Component/composer.json @@ -34,6 +34,7 @@ "pagerfanta/core": "^3.7 || ^4.0", "symfony/event-dispatcher": "^6.4 || ^7.1", "symfony/form": "^6.4 || ^7.1", + "symfony/framework-bundle": "^6.4 || ^7.1", "symfony/http-foundation": "^6.4 || ^7.1", "symfony/http-kernel": "^6.4 || ^7.1", "symfony/property-access": "^6.4 || ^7.1", diff --git a/src/Component/spec/Symfony/Request/State/ProviderSpec.php b/src/Component/spec/Symfony/Request/State/ProviderSpec.php deleted file mode 100644 index 05d3fbb89..000000000 --- a/src/Component/spec/Symfony/Request/State/ProviderSpec.php +++ /dev/null @@ -1,175 +0,0 @@ -beConstructedWith($locator, new RepositoryArgumentResolver(), $argumentParser); - } - - function it_is_initializable(): void - { - $this->shouldHaveType(Provider::class); - } - - function it_calls_repository_as_callable( - Operation $operation, - Request $request, - ): void { - $operation->getRepository()->willReturn([RepositoryWithCallables::class, 'find']); - $operation->getRepositoryArguments()->willReturn(null); - - $request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id']]); - $request->query = new InputBag([]); - $request->request = new InputBag(); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldHaveType(\stdClass::class); - $response->id->shouldReturn('my_id'); - } - - function it_calls_repository_as_string( - Operation $operation, - Request $request, - ContainerInterface $locator, - RepositoryInterface $repository, - \stdClass $stdClass, - ): void { - $operation->getRepository()->willReturn('App\Repository'); - $operation->getRepositoryMethod()->willReturn(null); - $operation->getRepositoryArguments()->willReturn(null); - - $request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]); - $request->query = new InputBag([]); - $request->request = new InputBag(); - - $locator->has('App\Repository')->willReturn(true); - $locator->get('App\Repository')->willReturn($repository); - - $repository->findOneBy(['id' => 'my_id'])->willReturn($stdClass); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldReturn($stdClass); - } - - function it_calls_create_paginator_by_default_on_collection_operations( - Request $request, - ContainerInterface $locator, - RepositoryInterface $repository, - Pagerfanta $pagerfanta, - ): void { - $operation = new Index(repository: 'App\Repository'); - - $request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]); - $request->query = new InputBag([]); - $request->request = new InputBag(); - - $locator->has('App\Repository')->willReturn(true); - $locator->get('App\Repository')->willReturn($repository); - - $repository->createPaginator()->willReturn($pagerfanta)->shouldBeCalled(); - $pagerfanta->setCurrentPage(1)->willReturn($pagerfanta)->shouldBeCalled(); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldReturn($pagerfanta); - } - - function it_sets_current_page_from_request_when_data_is_a_paginator( - Request $request, - ContainerInterface $locator, - RepositoryInterface $repository, - Pagerfanta $pagerfanta, - ): void { - $operation = new Index(repository: 'App\Repository'); - - $request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]); - $request->query = new InputBag(['page' => 42]); - $request->request = new InputBag(); - - $locator->has('App\Repository')->willReturn(true); - $locator->get('App\Repository')->willReturn($repository); - - $repository->createPaginator()->willReturn($pagerfanta)->shouldBeCalled(); - $pagerfanta->setCurrentPage(42)->willReturn($pagerfanta)->shouldBeCalled(); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldReturn($pagerfanta); - $pagerfanta->getCurrentPage()->willReturn(42); - } - - function it_calls_repository_as_string_with_specific_repository_method( - Operation $operation, - Request $request, - ContainerInterface $locator, - RepositoryInterface $repository, - \stdClass $stdClass, - ): void { - $operation->getRepository()->willReturn('App\Repository'); - $operation->getRepositoryMethod()->willReturn('find'); - $operation->getRepositoryArguments()->willReturn(null); - - $request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]); - $request->query = new InputBag([]); - $request->request = new InputBag(); - - $locator->has('App\Repository')->willReturn(true); - $locator->get('App\Repository')->willReturn($repository); - - $repository->find('my_id')->willReturn($stdClass); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldReturn($stdClass); - } - - function it_calls_repository_as_string_with_specific_repository_method_an_arguments( - Operation $operation, - Request $request, - ContainerInterface $locator, - RepositoryInterface $repository, - ArgumentParserInterface $argumentParser, - \stdClass $stdClass, - ): void { - $operation->getRepository()->willReturn('App\Repository'); - $operation->getRepositoryMethod()->willReturn('find'); - $operation->getRepositoryArguments()->willReturn(['id' => "request.attributes.get('id')"]); - - $argumentParser->parseExpression("request.attributes.get('id')")->willReturn('my_id'); - - $locator->has('App\Repository')->willReturn(true); - $locator->get('App\Repository')->willReturn($repository); - - $repository->find('my_id')->willReturn($stdClass); - - $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); - $response->shouldReturn($stdClass); - } -} diff --git a/src/Component/spec/Symfony/Routing/Factory/AttributesOperationRouteFactorySpec.php b/src/Component/spec/Symfony/Routing/Factory/AttributesOperationRouteFactorySpec.php index a55bc8421..2983df629 100644 --- a/src/Component/spec/Symfony/Routing/Factory/AttributesOperationRouteFactorySpec.php +++ b/src/Component/spec/Symfony/Routing/Factory/AttributesOperationRouteFactorySpec.php @@ -42,7 +42,6 @@ function let( new AttributesResourceMetadataCollectionFactory( $resourceRegistry->getWrappedObject(), new OperationRouteNameFactory(), - 'symfony', ), ); } diff --git a/src/Component/spec/Symfony/Routing/Factory/OperationRouteFactorySpec.php b/src/Component/spec/Symfony/Routing/Factory/OperationRouteFactorySpec.php index edc46a93f..4c2c09640 100644 --- a/src/Component/spec/Symfony/Routing/Factory/OperationRouteFactorySpec.php +++ b/src/Component/spec/Symfony/Routing/Factory/OperationRouteFactorySpec.php @@ -289,4 +289,22 @@ function it_generates_routes_with_vars( ], ]); } + + function it_generates_routes_with_requirements( + OperationRoutePathFactoryInterface $routePathFactory, + ): void { + $operation = new Index(routeRequirements: ['type' => 'country|province|zone']); + + $metadata = Metadata::fromAliasAndConfiguration('app.dummy', ['driver' => 'dummy_driver']); + + $routePathFactory->createRoutePath($operation, 'dummies')->willReturn('/dummies')->shouldBeCalled(); + + $route = $this->create( + $metadata, + new ResourceMetadata(alias: 'app.dummy'), + $operation, + ); + + $route->getRequirements()->shouldReturn(['type' => 'country|province|zone']); + } } diff --git a/src/Component/spec/Symfony/Session/Flash/FlashHelperSpec.php b/src/Component/spec/Symfony/Session/Flash/FlashHelperSpec.php index bd37c37fb..8ef6be2d1 100644 --- a/src/Component/spec/Symfony/Session/Flash/FlashHelperSpec.php +++ b/src/Component/spec/Symfony/Session/Flash/FlashHelperSpec.php @@ -91,6 +91,31 @@ function it_adds_success_flashes_with_fallback_message( $this->addSuccessFlash($operation, $context); } + function it_adds_success_flashes_with_custom_message( + Request $request, + SessionInterface $session, + FlashBagInterface $flashBag, + TranslatorBagInterface $translator, + MessageCatalogueInterface $messageCatalogue, + ): void { + $operation = (new Create(notificationMessage: 'app.dummy.shipped'))->withResource(new ResourceMetadata(alias: 'app.dummy', name: 'dummy', applicationName: 'app')); + $context = new Context(new RequestOption($request->getWrappedObject())); + + $request->getSession()->willReturn($session); + + $session->getBag('flashes')->willReturn($flashBag); + + $translator->getCatalogue()->willReturn($messageCatalogue); + + $messageCatalogue->has('app.dummy.shipped', 'flashes')->willReturn(true)->shouldBeCalled(); + + $translator->trans('app.dummy.shipped', ['%resource%' => 'Dummy'], 'flashes')->willReturn('Dummy was shipped successfully.')->shouldBeCalled(); + + $flashBag->add('success', 'Dummy was shipped successfully.')->shouldBeCalled(); + + $this->addSuccessFlash($operation, $context); + } + function it_adds_success_flashes_with_default_message_when_translator_is_not_a_bag( Request $request, SessionInterface $session, diff --git a/src/Component/src/Doctrine/Common/Metadata/Resource/Factory/DoctrineResourceMetadataCollectionFactory.php b/src/Component/src/Doctrine/Common/Metadata/Resource/Factory/DoctrineResourceMetadataCollectionFactory.php index b6141ac35..f4ae8daac 100644 --- a/src/Component/src/Doctrine/Common/Metadata/Resource/Factory/DoctrineResourceMetadataCollectionFactory.php +++ b/src/Component/src/Doctrine/Common/Metadata/Resource/Factory/DoctrineResourceMetadataCollectionFactory.php @@ -15,6 +15,7 @@ use Sylius\Resource\Doctrine\Common\State\PersistProcessor; use Sylius\Resource\Doctrine\Common\State\RemoveProcessor; +use Sylius\Resource\Doctrine\ORM\Metadata\Resource\Factory\DoctrineORMResourceMetadataCollectionFactory; use Sylius\Resource\Metadata\DeleteOperationInterface; use Sylius\Resource\Metadata\Operation; use Sylius\Resource\Metadata\Operations; @@ -29,6 +30,13 @@ public function __construct( private RegistryInterface $resourceRegistry, private ResourceMetadataCollectionFactoryInterface $decorated, ) { + trigger_deprecation( + 'sylius/resource', + '1.13', + 'The "%s" class is deprecated, use "%s instead. It will be removed in 2.0.', + self::class, + DoctrineORMResourceMetadataCollectionFactory::class, + ); } public function create(string $resourceClass): ResourceMetadataCollection diff --git a/src/Component/src/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactory.php b/src/Component/src/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactory.php new file mode 100644 index 000000000..93293d7a3 --- /dev/null +++ b/src/Component/src/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactory.php @@ -0,0 +1,103 @@ +decorated->create($resourceClass); + + /** @var ResourceMetadata $resource */ + foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + $operations = $resource->getOperations() ?? new Operations(); + $entityClass = $resource->getClass(); + + if (null === $entityClass) { + continue; + } + + /** @var Operation $operation */ + foreach ($operations as $operation) { + /** @var string $key */ + $key = $operation->getName(); + $entityManager = $this->managerRegistry->getManagerForClass($entityClass); + + if (!$entityManager instanceof EntityManagerInterface) { + $operations->add($key, $operation); + + continue; + } + + $operations->add($key, $this->addDefaults($operation)); + } + + $resource = $resource->withOperations($operations); + $resourceCollectionMetadata[$i] = $resource; + } + + return $resourceCollectionMetadata; + } + + private function addDefaults(Operation $operation): Operation + { + $operation = $operation->withProvider($this->getProvider($operation)); + + return $operation->withProcessor($this->getProcessor($operation)); + } + + private function getProvider(Operation $operation): callable|string|null + { + if (null !== $provider = $operation->getProvider()) { + return $provider; + } + + if ($operation instanceof GridAwareOperationInterface && null !== $operation->getGrid()) { + return null; + } + + return 'sylius.state_provider.doctrine.orm.state.provider'; + } + + private function getProcessor(Operation $operation): callable|string + { + if (null !== $processor = $operation->getProcessor()) { + return $processor; + } + + if ($operation instanceof DeleteOperationInterface) { + return RemoveProcessor::class; + } + + return PersistProcessor::class; + } +} diff --git a/src/Component/src/Symfony/Request/State/Provider.php b/src/Component/src/Doctrine/ORM/State/Provider.php similarity index 52% rename from src/Component/src/Symfony/Request/State/Provider.php rename to src/Component/src/Doctrine/ORM/State/Provider.php index cea4eda91..cac55efa3 100644 --- a/src/Component/src/Symfony/Request/State/Provider.php +++ b/src/Component/src/Doctrine/ORM/State/Provider.php @@ -11,9 +11,11 @@ declare(strict_types=1); -namespace Sylius\Resource\Symfony\Request\State; +namespace Sylius\Resource\Doctrine\ORM\State; -use Pagerfanta\Pagerfanta; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; +use Pagerfanta\PagerfantaInterface; use Psr\Container\ContainerInterface; use Sylius\Resource\Context\Context; use Sylius\Resource\Context\Option\RequestOption; @@ -31,49 +33,33 @@ final class Provider implements ProviderInterface { public function __construct( - private ContainerInterface $locator, + private ManagerRegistry $managerRegistry, private RepositoryArgumentResolver $argumentResolver, private ArgumentParserInterface $argumentParser, + private ContainerInterface $locator, ) { } public function provide(Operation $operation, Context $context): object|array|null { $request = $context->get(RequestOption::class)?->request(); - $repository = $operation->getRepository(); - if ( - null === $request || - null === $repository - ) { + if (null === $request) { return null; } + $repository = $operation->getRepository(); $repositoryInstance = null; - $arguments = $this->parseArgumentValues($operation->getRepositoryArguments() ?? []); - - if (\is_string($repository)) { - $defaultMethod = $operation instanceof CollectionOperationInterface ? 'createPaginator' : 'findOneBy'; - - if ($operation instanceof BulkOperationInterface) { - $defaultMethod = 'findById'; - } - - $method = $operation->getRepositoryMethod() ?? $defaultMethod; - if (!$this->locator->has($repository)) { - throw new \RuntimeException(sprintf('Repository "%s" not found on operation "%s"', $repository, $operation->getName() ?? '')); - } - - $repositoryInstance = $this->locator->get($repository); - - // make it as callable - /** @var callable $repository */ - $repository = [$repositoryInstance, $method]; + if (\is_callable($repository)) { + $callableRepository = $repository; + } else { + $repositoryInstance = $this->getRepositoryInstance($operation); + $callableRepository = $this->createCallableRepository($operation, $repositoryInstance); } try { - $reflector = CallableReflection::from($repository); + $reflector = CallableReflection::from($callableRepository); } catch (\ReflectionException $exception) { if (null === $repositoryInstance) { throw $exception; @@ -85,13 +71,15 @@ public function provide(Operation $operation, Context $context): object|array|nu $reflector = CallableReflection::from($callable); } + $arguments = $this->parseArgumentValues($operation->getRepositoryArguments() ?? []); + if ([] === $arguments) { $arguments = $this->argumentResolver->getArguments($request, $reflector); } - $data = $repository(...$arguments); + $data = $callableRepository(...$arguments); - if ($data instanceof Pagerfanta) { + if ($data instanceof PagerfantaInterface) { $currentPage = $request->query->getInt('page', 1); $data->setCurrentPage($currentPage); } @@ -107,4 +95,43 @@ private function parseArgumentValues(array $arguments): array return $arguments; } + + private function createCallableRepository(Operation $operation, mixed $repositoryInstance): callable + { + $defaultMethod = $operation instanceof CollectionOperationInterface ? 'createPaginator' : 'findOneBy'; + + if ($operation instanceof BulkOperationInterface) { + $defaultMethod = 'findById'; + } + + $method = $operation->getRepositoryMethod() ?? $defaultMethod; + + // make it as callable + /** @var callable $repository */ + $repository = [$repositoryInstance, $method]; + + return $repository; + } + + private function getRepositoryInstance(Operation $operation): mixed + { + /** @var string|null $repository */ + $repository = $operation->getRepository(); + + if (null === $repository) { + /** @var class-string $entityClass */ + $entityClass = $operation->getResource()?->getClass(); + + /** @var EntityManagerInterface $manager */ + $manager = $this->managerRegistry->getManagerForClass($entityClass); + + return $manager->getRepository($entityClass); + } + + if (!$this->locator->has($repository)) { + throw new \RuntimeException(sprintf('Repository "%s" not found on operation "%s"', $repository, $operation->getName() ?? '')); + } + + return $this->locator->get($repository); + } } diff --git a/src/Component/src/Grid/State/RequestGridProvider.php b/src/Component/src/Grid/State/RequestGridProvider.php index 8b5311840..79237872f 100644 --- a/src/Component/src/Grid/State/RequestGridProvider.php +++ b/src/Component/src/Grid/State/RequestGridProvider.php @@ -13,7 +13,7 @@ namespace Sylius\Resource\Grid\State; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Sylius\Component\Grid\Parameters; use Sylius\Component\Grid\Provider\GridProviderInterface; use Sylius\Resource\Context\Context; @@ -63,7 +63,7 @@ public function provide(Operation $operation, Context $context): object|array|nu $data = $gridView->getData(); - if ($data instanceof Pagerfanta) { + if ($data instanceof PagerfantaInterface) { $currentPage = $request->query->getInt('page', 1); $data->setCurrentPage($currentPage); diff --git a/src/Component/src/Metadata/Api/Delete.php b/src/Component/src/Metadata/Api/Delete.php index edc92537f..63447dcab 100644 --- a/src/Component/src/Metadata/Api/Delete.php +++ b/src/Component/src/Metadata/Api/Delete.php @@ -24,7 +24,9 @@ final class Delete extends HttpOperation implements DeleteOperationInterface, Ap { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['DELETE'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'delete', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/Api/Get.php b/src/Component/src/Metadata/Api/Get.php index ae77d6303..bba4e7196 100644 --- a/src/Component/src/Metadata/Api/Get.php +++ b/src/Component/src/Metadata/Api/Get.php @@ -24,7 +24,9 @@ final class Get extends HttpOperation implements ShowOperationInterface, ApiOper { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['GET'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'get', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/Api/GetCollection.php b/src/Component/src/Metadata/Api/GetCollection.php index b4cbe0ad1..372bf7bce 100644 --- a/src/Component/src/Metadata/Api/GetCollection.php +++ b/src/Component/src/Metadata/Api/GetCollection.php @@ -24,7 +24,9 @@ final class GetCollection extends HttpOperation implements CollectionOperationIn { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['GET'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'get_collection', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/Api/Patch.php b/src/Component/src/Metadata/Api/Patch.php index 803418410..a0e8f3576 100644 --- a/src/Component/src/Metadata/Api/Patch.php +++ b/src/Component/src/Metadata/Api/Patch.php @@ -24,7 +24,9 @@ final class Patch extends HttpOperation implements UpdateOperationInterface, Api { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['PATCH'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'patch', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/Api/Post.php b/src/Component/src/Metadata/Api/Post.php index 4a0e96eb3..2a87cbdd6 100644 --- a/src/Component/src/Metadata/Api/Post.php +++ b/src/Component/src/Metadata/Api/Post.php @@ -24,7 +24,9 @@ final class Post extends HttpOperation implements CreateOperationInterface, ApiO { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'post', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/Api/Put.php b/src/Component/src/Metadata/Api/Put.php index d4a954679..b8a9f4ed9 100644 --- a/src/Component/src/Metadata/Api/Put.php +++ b/src/Component/src/Metadata/Api/Put.php @@ -24,7 +24,9 @@ final class Put extends HttpOperation implements UpdateOperationInterface, ApiOp { public function __construct( ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -45,12 +47,15 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ) { parent::__construct( methods: ['PUT'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'put', name: $name, @@ -71,6 +76,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/ApplyStateMachineTransition.php b/src/Component/src/Metadata/ApplyStateMachineTransition.php index 10488d6c5..8318d4997 100644 --- a/src/Component/src/Metadata/ApplyStateMachineTransition.php +++ b/src/Component/src/Metadata/ApplyStateMachineTransition.php @@ -22,6 +22,7 @@ final class ApplyStateMachineTransition extends HttpOperation implements UpdateO public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, ?string $template = null, ?string $shortName = null, @@ -36,6 +37,7 @@ public function __construct( ?bool $validate = null, ?string $formType = null, ?array $formOptions = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, private ?string $stateMachineComponent = null, private ?string $stateMachineTransition = null, @@ -44,6 +46,7 @@ public function __construct( parent::__construct( methods: $methods ?? ['PUT', 'PATCH', 'POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, template: $template, shortName: $shortName ?? $stateMachineTransition ?? 'apply_state_machine_transition', @@ -58,6 +61,7 @@ public function __construct( validate: $validate ?? false, formType: $formType, formOptions: $formOptions, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, ); } diff --git a/src/Component/src/Metadata/AsResource.php b/src/Component/src/Metadata/AsResource.php index f2eb89938..df0925b2e 100644 --- a/src/Component/src/Metadata/AsResource.php +++ b/src/Component/src/Metadata/AsResource.php @@ -16,6 +16,9 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class AsResource { + /** + * @param class-string|null $class + */ public function __construct( private ?string $alias = null, private ?string $section = null, diff --git a/src/Component/src/Metadata/BulkDelete.php b/src/Component/src/Metadata/BulkDelete.php index b0798d492..b22724041 100644 --- a/src/Component/src/Metadata/BulkDelete.php +++ b/src/Component/src/Metadata/BulkDelete.php @@ -22,7 +22,9 @@ final class BulkDelete extends HttpOperation implements DeleteOperationInterface public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -37,6 +39,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, ?array $vars = null, @@ -44,7 +47,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['DELETE', 'POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'bulk_delete', name: $name, @@ -59,6 +64,7 @@ public function __construct( formType: $formType, formOptions: $formOptions, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, vars: $vars, diff --git a/src/Component/src/Metadata/BulkUpdate.php b/src/Component/src/Metadata/BulkUpdate.php index 9b5c1f9b9..3ddccfb70 100644 --- a/src/Component/src/Metadata/BulkUpdate.php +++ b/src/Component/src/Metadata/BulkUpdate.php @@ -22,7 +22,9 @@ final class BulkUpdate extends HttpOperation implements UpdateOperationInterface public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -41,6 +43,7 @@ public function __construct( ?array $formOptions = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, ?array $vars = null, @@ -51,7 +54,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['PUT', 'PATCH'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'bulk_update', name: $name, @@ -70,6 +75,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, vars: $vars, diff --git a/src/Component/src/Metadata/Create.php b/src/Component/src/Metadata/Create.php index cf43df572..90d2ce372 100644 --- a/src/Component/src/Metadata/Create.php +++ b/src/Component/src/Metadata/Create.php @@ -25,7 +25,9 @@ final class Create extends HttpOperation implements CreateOperationInterface, St public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -47,6 +49,7 @@ public function __construct( ?array $formOptions = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, string|callable|null $twigContextFactory = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, @@ -58,7 +61,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['GET', 'POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'create', name: $name, @@ -77,6 +82,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, twigContextFactory: $twigContextFactory, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, diff --git a/src/Component/src/Metadata/Delete.php b/src/Component/src/Metadata/Delete.php index 21eced536..91929c5d3 100644 --- a/src/Component/src/Metadata/Delete.php +++ b/src/Component/src/Metadata/Delete.php @@ -22,7 +22,9 @@ final class Delete extends HttpOperation implements DeleteOperationInterface public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -41,6 +43,7 @@ public function __construct( ?array $formOptions = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, string|callable|null $twigContextFactory = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, @@ -49,7 +52,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['DELETE', 'POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'delete', name: $name, @@ -68,6 +73,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, twigContextFactory: $twigContextFactory, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, diff --git a/src/Component/src/Metadata/Extractor/MetadataExtractorInterface.php b/src/Component/src/Metadata/Extractor/MetadataExtractorInterface.php new file mode 100644 index 000000000..4c8dd48b2 --- /dev/null +++ b/src/Component/src/Metadata/Extractor/MetadataExtractorInterface.php @@ -0,0 +1,24 @@ +getResourceFilePaths() as $filePath) { + if (!is_readable($filePath)) { + continue; + } + + $resource = $this->getPHPFileClosure($filePath)(); + + if (!$resource instanceof ResourceMetadata) { + continue; + } + + $resourceReflection = new \ReflectionClass($resource); + + foreach ($resourceReflection->getProperties() as $property) { + $property->setAccessible(true); + $resolvedValue = $this->resolve($property->getValue($resource)); + $property->setValue($resource, $resolvedValue); + } + + $metadata[] = $resource; + } + + return $metadata; + } + + private function getResourceFilePaths(): iterable + { + foreach ($this->createFinder() as $file) { + yield $file->getPathname(); + } + } + + private function createFinder(): Finder + { + $finder = (new Finder())->files(); + + foreach ($this->resourceMapping['imports'] ?? [] as $path) { + $finder->in($path); + } + + return $finder->files(); + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ + private function getPHPFileClosure(string $filePath): \Closure + { + return \Closure::bind(function () use ($filePath): mixed { + return require $filePath; + }, null, null); + } + + /** + * Recursively replaces placeholders with the service container parameters. + * + * @see https://github.com/symfony/symfony/blob/6fec32c/src/Symfony/Bundle/FrameworkBundle/Routing/Router.php + * + * @param mixed $value The source which might contain "%placeholders%" + * + * @throws \RuntimeException When a container value is not a string or a numeric value + * + * @return mixed The source with the placeholders replaced by the container + * parameters. Arrays are resolved recursively. + */ + private function resolve(mixed $value): mixed + { + $container = $this->container; + + if (null === $container) { + return $value; + } + + if (\is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->resolve($val); + } + + return $value; + } + + if (!\is_string($value)) { + return $value; + } + + $escapedValue = preg_replace_callback('/%%|%([^%\s]++)%/', function ($match) use ($value, $container) { + $parameter = $match[1] ?? null; + + // skip %% + if (!isset($parameter)) { + return '%%'; + } + + if (preg_match('/^env\(\w+\)$/', $parameter)) { + throw new \RuntimeException(\sprintf('Using "%%%s%%" is not allowed in routing configuration.', $parameter)); + } + + if (\array_key_exists($parameter, $this->collectedParameters)) { + return $this->collectedParameters[$parameter]; + } + + if ($container instanceof SymfonyContainerInterface) { + $resolved = $container->getParameter($parameter); + } else { + $resolved = $container->get($parameter); + } + + if (\is_string($resolved) || is_numeric($resolved)) { + $this->collectedParameters[$parameter] = $resolved; + + return (string) $resolved; + } + + throw new \RuntimeException(\sprintf('The container parameter "%s", used in the resource configuration value "%s", must be a string or numeric, but it is of type %s.', $parameter, $value, \gettype($resolved))); + }, $value); + + return str_replace('%%', '%', $escapedValue ?? ''); + } +} diff --git a/src/Component/src/Metadata/HttpOperation.php b/src/Component/src/Metadata/HttpOperation.php index 194166b67..cb16b5be9 100644 --- a/src/Component/src/Metadata/HttpOperation.php +++ b/src/Component/src/Metadata/HttpOperation.php @@ -26,6 +26,7 @@ public function __construct( protected ?string $path = null, protected ?string $routeName = null, protected ?string $routePrefix = null, + protected ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -46,6 +47,7 @@ public function __construct( ?array $denormalizationContext = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, string|callable|null $twigContextFactory = null, protected ?string $redirectToRoute = null, protected ?array $redirectArguments = null, @@ -72,6 +74,7 @@ public function __construct( denormalizationContext: $denormalizationContext, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, ); $this->twigContextFactory = $twigContextFactory; @@ -129,6 +132,19 @@ public function withRoutePrefix(?string $routePrefix): self return $self; } + public function getRouteRequirements(): ?array + { + return $this->routeRequirements; + } + + public function withRouteRequirements(array $routeRequirements): self + { + $self = clone $this; + $self->routeRequirements = $routeRequirements; + + return $self; + } + public function getTwigContextFactory(): callable|string|null { return $this->twigContextFactory; diff --git a/src/Component/src/Metadata/Index.php b/src/Component/src/Metadata/Index.php index ae64fe889..7e1cadbd3 100644 --- a/src/Component/src/Metadata/Index.php +++ b/src/Component/src/Metadata/Index.php @@ -22,7 +22,9 @@ final class Index extends HttpOperation implements CollectionOperationInterface, public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -40,6 +42,7 @@ public function __construct( ?string $formType = null, ?array $formOptions = null, ?string $eventShortName = null, + ?string $notificationMessage = null, ?array $validationContext = null, string|callable|null $twigContextFactory = null, ?string $redirectToRoute = null, @@ -49,7 +52,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['GET'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'index', name: $name, @@ -68,6 +73,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, twigContextFactory: $twigContextFactory, redirectToRoute: $redirectToRoute, vars: $vars, diff --git a/src/Component/src/Metadata/Operation.php b/src/Component/src/Metadata/Operation.php index e47e89fc2..d1b70282a 100644 --- a/src/Component/src/Metadata/Operation.php +++ b/src/Component/src/Metadata/Operation.php @@ -53,6 +53,7 @@ public function __construct( protected ?array $denormalizationContext = null, protected ?array $validationContext = null, protected ?string $eventShortName = null, + protected ?string $notificationMessage = null, ) { $this->provider = $provider; $this->processor = $processor; @@ -332,4 +333,17 @@ public function withEventShortName(string $eventShortName): self return $self; } + + public function getNotificationMessage(): ?string + { + return $this->notificationMessage; + } + + public function withNotificationMessage(string $notificationMessage): self + { + $self = clone $this; + $self->notificationMessage = $notificationMessage; + + return $self; + } } diff --git a/src/Component/src/Metadata/Operation/CustomPhpFileOperationUpdater.php b/src/Component/src/Metadata/Operation/CustomPhpFileOperationUpdater.php new file mode 100644 index 000000000..faf8df833 --- /dev/null +++ b/src/Component/src/Metadata/Operation/CustomPhpFileOperationUpdater.php @@ -0,0 +1,94 @@ +getResourceFilePaths() as $filePath) { + if (!is_readable($filePath)) { + continue; + } + + $resource = $this->getPHPFileClosure($filePath)(); + + if (!$resource instanceof \Closure) { + continue; + } + + $resourceReflection = new \ReflectionFunction($resource); + + if (1 !== $resourceReflection->getNumberOfParameters()) { + continue; + } + + $firstParameterType = ($resourceReflection->getParameters()[0] ?? null)?->getType(); + + if (!$firstParameterType instanceof \ReflectionNamedType) { + continue; + } + + // Check if the closure parameter is an operation + if (!is_a($firstParameterType->getName(), Operation::class, true)) { + continue; + } + + $operation = $resource($operation); + } + + return $operation; + } + + private function getResourceFilePaths(): iterable + { + foreach ($this->createFinder() as $file) { + yield $file->getPathname(); + } + } + + private function createFinder(): Finder + { + $finder = (new Finder())->files(); + + foreach ($this->resourceMapping['imports'] ?? [] as $path) { + $finder->in($path); + } + + return $finder->files(); + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + */ + private function getPHPFileClosure(string $filePath): \Closure + { + return \Closure::bind(function () use ($filePath): mixed { + return require $filePath; + }, null, null); + } +} diff --git a/src/Component/src/Metadata/Operation/OperationUpdaterInterface.php b/src/Component/src/Metadata/Operation/OperationUpdaterInterface.php new file mode 100644 index 000000000..1855c7cd0 --- /dev/null +++ b/src/Component/src/Metadata/Operation/OperationUpdaterInterface.php @@ -0,0 +1,21 @@ +decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } $attributes = ClassReflection::getClassAttributes($resourceClass); @@ -48,6 +52,7 @@ public function create(string $resourceClass): ResourceMetadataCollection /** * @param \ReflectionAttribute[] $attributes + * @param class-string $resourceClass * * @return ResourceMetadata[] */ @@ -118,6 +123,9 @@ private function buildResourceOperations(array $attributes, string $resourceClas return $resources; } + /** + * @param class-string $resourceClass + */ private function getResourceWithDefaults(string $resourceClass, ResourceMetadata $resource, MetadataInterface $resourceConfiguration): ResourceMetadata { $resource = $resource->withClass($resourceClass); @@ -171,10 +179,6 @@ private function getOperationWithDefaults(ResourceMetadata $resource, Operation $operation = $operation->withResource($resource); - if (null === $operation->getRepository()) { - $operation = $operation->withRepository($resourceConfiguration->getServiceId('repository')); - } - if (null === $operation->getFormType()) { $formType = $resource->getFormType() ?? $resourceConfiguration->getClass('form'); $operation = $operation->withFormType($formType); diff --git a/src/Component/src/Metadata/Resource/Factory/AttributesResourceNameCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/AttributesResourceNameCollectionFactory.php new file mode 100644 index 000000000..859070653 --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/AttributesResourceNameCollectionFactory.php @@ -0,0 +1,61 @@ +decorated) { + foreach ($this->decorated->create() as $resourceClass) { + $classes[$resourceClass] = true; + } + } + + $paths = $this->mapping['paths'] ?? []; + + foreach (ClassReflection::getResourcesByPaths($paths) as $resourceClass) { + if ([] === ClassReflection::getClassAttributes($resourceClass, AsResource::class)) { + continue; + } + + $classes[$resourceClass] = true; + } + + return new ResourceNameCollection(array_keys($classes)); + } +} diff --git a/src/Component/src/Metadata/Resource/Factory/CustomResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/CustomResourceMetadataCollectionFactory.php new file mode 100644 index 000000000..52feaeac1 --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/CustomResourceMetadataCollectionFactory.php @@ -0,0 +1,58 @@ +decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + $newMetadataCollection = new ResourceMetadataCollection(); + + /** @var ResourceMetadata $resourceMetadata */ + foreach ($resourceMetadataCollection as $resourceMetadata) { + $operations = $resourceMetadata->getOperations() ?? new Operations(); + + /** @var Operation $operation */ + foreach ($operations as $operation) { + $operationName = $operation->getName(); + Assert::notNull($operationName); + $operations->add($operationName, $this->metadataUpdater->update($operation)); + } + + $newMetadataCollection[] = $resourceMetadata->withOperations($operations); + } + + return $newMetadataCollection; + } +} diff --git a/src/Component/src/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php index 7311c551d..23bf61d29 100644 --- a/src/Component/src/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php +++ b/src/Component/src/Metadata/Resource/Factory/EventShortNameResourceMetadataCollectionFactory.php @@ -29,10 +29,10 @@ public function __construct( public function create(string $resourceClass): ResourceMetadataCollection { - $resourceCollectionMetadata = $this->decorated->create($resourceClass); + $resourceMetadataCollection = $this->decorated->create($resourceClass); /** @var ResourceMetadata $resource */ - foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + foreach ($resourceMetadataCollection->getIterator() as $i => $resource) { $operations = $resource->getOperations() ?? new Operations(); /** @var Operation $operation */ @@ -45,10 +45,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resource = $resource->withOperations($operations); - $resourceCollectionMetadata[$i] = $resource; + $resourceMetadataCollection[$i] = $resource; } - return $resourceCollectionMetadata; + return $resourceMetadataCollection; } private function addDefaults(ResourceMetadata $resource, Operation $operation): Operation diff --git a/src/Component/src/Metadata/Resource/Factory/FactoryResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/FactoryResourceMetadataCollectionFactory.php index 26eb23c8e..bc6c10c4e 100644 --- a/src/Component/src/Metadata/Resource/Factory/FactoryResourceMetadataCollectionFactory.php +++ b/src/Component/src/Metadata/Resource/Factory/FactoryResourceMetadataCollectionFactory.php @@ -31,10 +31,10 @@ public function __construct( public function create(string $resourceClass): ResourceMetadataCollection { - $resourceCollectionMetadata = $this->decorated->create($resourceClass); + $resourceMetadataCollection = $this->decorated->create($resourceClass); /** @var ResourceMetadata $resource */ - foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + foreach ($resourceMetadataCollection->getIterator() as $i => $resource) { $resourceConfiguration = $this->resourceRegistry->get($resource->getAlias() ?? ''); $operations = $resource->getOperations() ?? new Operations(); @@ -55,10 +55,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resource = $resource->withOperations($operations); - $resourceCollectionMetadata[$i] = $resource; + $resourceMetadataCollection[$i] = $resource; } - return $resourceCollectionMetadata; + return $resourceMetadataCollection; } private function addDefaults(MetadataInterface $resourceConfiguration, ResourceMetadata $resource, FactoryAwareOperationInterface $operation): FactoryAwareOperationInterface diff --git a/src/Component/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Component/src/Metadata/Resource/Factory/OperationDefaultsTrait.php new file mode 100644 index 000000000..3641f209b --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/OperationDefaultsTrait.php @@ -0,0 +1,134 @@ +withClass($resourceClass); + + if (null === $resource->getAlias()) { + $resource = $resource->withAlias($resourceConfiguration->getAlias()); + } + + if (null === $resource->getApplicationName()) { + $resource = $resource->withApplicationName($resourceConfiguration->getApplicationName()); + } + + if (null === $resource->getName()) { + $resource = $resource->withName($resourceConfiguration->getName()); + } + + return $resource; + } + + private function getOperationWithDefaults(OperationRouteNameFactory $operationRouteNameFactory, RegistryInterface $resourceRegistry, ResourceMetadata $resource, Operation $operation): array + { + $resourceConfiguration = $resourceRegistry->get($resource->getAlias() ?? ''); + + $operation = $operation->withResource($resource); + + if (null === $resource->getName()) { + $resourceName = $resourceConfiguration->getName(); + + $resource = $resource->withName($resourceName); + $operation = $operation->withResource($resource); + } + + if (null === $resource->getPluralName()) { + $resourcePluralName = $resourceConfiguration->getPluralName(); + + $resource = $resource->withPluralName($resourcePluralName); + $operation = $operation->withResource($resource); + } + + if (null === $operation->getNormalizationContext()) { + $operation = $operation->withNormalizationContext($resource->getNormalizationContext()); + } + + if (null === $operation->getDenormalizationContext()) { + $operation = $operation->withDenormalizationContext($resource->getDenormalizationContext()); + } + + if (null === $operation->getValidationContext()) { + $operation = $operation->withValidationContext($resource->getValidationContext()); + } + + $operation = $operation->withResource($resource); + + if (null === $operation->getFormType()) { + $formType = $resource->getFormType() ?? $resourceConfiguration->getClass('form'); + $operation = $operation->withFormType($formType); + } + + $formOptions = $this->buildFormOptions($operation, $resourceConfiguration); + $operation = $operation->withFormOptions($formOptions); + + if ($operation instanceof HttpOperation) { + if (null === $operation->getRoutePrefix()) { + $operation = $operation->withRoutePrefix($resource->getRoutePrefix() ?? null); + } + + if (null === $operation->getTwigContextFactory()) { + $operation = $operation->withTwigContextFactory('sylius.twig.context.factory.default'); + } + + if (null === $routeName = $operation->getRouteName()) { + $routeName = $operationRouteNameFactory->createRouteName($operation); + $operation = $operation->withRouteName($routeName); + } + + if (null === $operation->getResponder()) { + $operation = $operation->withResponder(Responder::class); + } + + $operation = $operation->withName($routeName); + } + + $operationName = $operation->getName(); + + return [$operationName, $operation]; + } + + private function buildFormOptions(Operation $operation, MetadataInterface $resourceConfiguration): array + { + $formOptions = array_merge( + ['data_class' => $resourceConfiguration->getClass('model')], + $operation->getFormOptions() ?? [], + ); + + $validationGroups = $operation->getValidationContext()['groups'] ?? null; + + if (null !== $validationGroups) { + $formOptions = array_merge(['validation_groups' => $validationGroups], $formOptions); + } + + return $formOptions; + } +} diff --git a/src/Component/src/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php new file mode 100644 index 000000000..97acb87f7 --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/PhpFileResourceMetadataCollectionFactory.php @@ -0,0 +1,73 @@ +decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + foreach ($this->metadataExtractor->extract() as $resource) { + if ($resourceClass !== $resource->getClass()) { + continue; + } + + $resourceAlias = $resource->getAlias(); + + if (null !== $resourceAlias) { + $resourceConfiguration = $this->resourceRegistry->get($resource->getAlias() ?? ''); + } else { + $resourceConfiguration = $this->resourceRegistry->getByClass($resourceClass); + } + + $resource = $this->getResourceWithDefaults($resourceClass, $resource, $resourceConfiguration); + + $operations = []; + /** @var Operation $operation */ + foreach ($resource->getOperations() ?? new Operations() as $operation) { + [$key, $operation] = $this->getOperationWithDefaults($this->operationRouteNameFactory, $this->resourceRegistry, $resource, $operation); + $operations[$key] = $operation; + } + + if ($operations) { + $resource = $resource->withOperations(new Operations($operations)); + } + + $resourceMetadataCollection[] = $resource; + } + + return $resourceMetadataCollection; + } +} diff --git a/src/Component/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php new file mode 100644 index 000000000..401365050 --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/PhpFileResourceNameCollectionFactory.php @@ -0,0 +1,51 @@ +decorated) { + foreach ($this->decorated->create() as $resourceClass) { + $classes[$resourceClass] = true; + } + } + + foreach ($this->metadataExtractor->extract() as $resource) { + $resourceClass = $resource->getClass(); + + if (null === $resourceClass) { + continue; + } + + $classes[$resourceClass] = true; + } + + return new ResourceNameCollection(array_keys($classes)); + } +} diff --git a/src/Component/src/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactory.php index f5f57d1fe..13045c05f 100644 --- a/src/Component/src/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactory.php +++ b/src/Component/src/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactory.php @@ -19,7 +19,6 @@ use Sylius\Resource\Metadata\Operations; use Sylius\Resource\Metadata\Resource\ResourceMetadataCollection; use Sylius\Resource\Metadata\ResourceMetadata; -use Sylius\Resource\Symfony\Request\State\Provider; final class ProviderResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { @@ -30,10 +29,10 @@ public function __construct( public function create(string $resourceClass): ResourceMetadataCollection { - $resourceCollectionMetadata = $this->decorated->create($resourceClass); + $resourceMetadataCollection = $this->decorated->create($resourceClass); /** @var ResourceMetadata $resource */ - foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + foreach ($resourceMetadataCollection->getIterator() as $i => $resource) { $operations = $resource->getOperations() ?? new Operations(); /** @var Operation $operation */ @@ -46,10 +45,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resource = $resource->withOperations($operations); - $resourceCollectionMetadata[$i] = $resource; + $resourceMetadataCollection[$i] = $resource; } - return $resourceCollectionMetadata; + return $resourceMetadataCollection; } private function addDefaults(Operation $operation): Operation @@ -62,10 +61,6 @@ private function addDefaults(Operation $operation): Operation $operation = $operation->withProvider(RequestGridProvider::class); } - if (null === $operation->getProvider()) { - $operation = $operation->withProvider(Provider::class); - } - return $operation; } } diff --git a/src/Component/src/Metadata/Resource/Factory/ResourceNameCollectionFactoryInterface.php b/src/Component/src/Metadata/Resource/Factory/ResourceNameCollectionFactoryInterface.php new file mode 100644 index 000000000..c35d9c2a5 --- /dev/null +++ b/src/Component/src/Metadata/Resource/Factory/ResourceNameCollectionFactoryInterface.php @@ -0,0 +1,29 @@ +decorated->create($resourceClass); + $resourceMetadataCollection = $this->decorated->create($resourceClass); /** @var ResourceMetadata $resource */ - foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + foreach ($resourceMetadataCollection->getIterator() as $i => $resource) { $resourceConfiguration = $this->resourceRegistry->get($resource->getAlias() ?? ''); $operations = $resource->getOperations() ?? new Operations(); @@ -50,10 +50,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resource = $resource->withOperations($operations); - $resourceCollectionMetadata[$i] = $resource; + $resourceMetadataCollection[$i] = $resource; } - return $resourceCollectionMetadata; + return $resourceMetadataCollection; } private function addDefaults(MetadataInterface $resourceConfiguration, Operation $operation): Operation diff --git a/src/Component/src/Metadata/Resource/Factory/TemplatesDirResourceMetadataCollectionFactory.php b/src/Component/src/Metadata/Resource/Factory/TemplatesDirResourceMetadataCollectionFactory.php index 7e1608cda..d112e3f58 100644 --- a/src/Component/src/Metadata/Resource/Factory/TemplatesDirResourceMetadataCollectionFactory.php +++ b/src/Component/src/Metadata/Resource/Factory/TemplatesDirResourceMetadataCollectionFactory.php @@ -28,10 +28,10 @@ public function __construct( public function create(string $resourceClass): ResourceMetadataCollection { - $resourceCollectionMetadata = $this->decorated->create($resourceClass); + $resourceMetadataCollection = $this->decorated->create($resourceClass); /** @var ResourceMetadata $resource */ - foreach ($resourceCollectionMetadata->getIterator() as $i => $resource) { + foreach ($resourceMetadataCollection->getIterator() as $i => $resource) { $operations = $resource->getOperations() ?? new Operations(); /** @var Operation $operation */ @@ -44,10 +44,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resource = $resource->withOperations($operations); - $resourceCollectionMetadata[$i] = $resource; + $resourceMetadataCollection[$i] = $resource; } - return $resourceCollectionMetadata; + return $resourceMetadataCollection; } private function addDefaults(ResourceMetadata $resource, Operation $operation): Operation diff --git a/src/Component/src/Metadata/Resource/ResourceNameCollection.php b/src/Component/src/Metadata/Resource/ResourceNameCollection.php new file mode 100644 index 000000000..cec732846 --- /dev/null +++ b/src/Component/src/Metadata/Resource/ResourceNameCollection.php @@ -0,0 +1,42 @@ + + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->names); + } + + public function count(): int + { + return \count($this->names); + } +} diff --git a/src/Component/src/Metadata/ResourceMetadata.php b/src/Component/src/Metadata/ResourceMetadata.php index c2ee0a9c3..404618c3f 100644 --- a/src/Component/src/Metadata/ResourceMetadata.php +++ b/src/Component/src/Metadata/ResourceMetadata.php @@ -17,6 +17,9 @@ final class ResourceMetadata { private ?Operations $operations; + /** + * @param class-string|null $class + */ public function __construct( private ?string $alias = null, private ?string $section = null, @@ -36,13 +39,27 @@ public function __construct( ?array $operations = null, ) { $this->operations = null === $operations ? null : new Operations($operations); + + if (null !== $driver && false !== $driver) { + trigger_deprecation( + 'sylius/resource', + '1.13', + 'Using driver is deprecated. If your resource is managed by Doctrine you have nothing to do, otherwise use a custom provider.', + ); + } } + /** + * @return class-string|null + */ public function getClass(): ?string { return $this->class; } + /** + * @param class-string $class + */ public function withClass(string $class): self { $self = clone $this; diff --git a/src/Component/src/Metadata/Show.php b/src/Component/src/Metadata/Show.php index 72bbe12e2..79351610c 100644 --- a/src/Component/src/Metadata/Show.php +++ b/src/Component/src/Metadata/Show.php @@ -22,7 +22,9 @@ final class Show extends HttpOperation implements ShowOperationInterface public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -41,6 +43,7 @@ public function __construct( ?array $formOptions = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, string|callable|null $twigContextFactory = null, ?string $redirectToRoute = null, ?array $vars = null, @@ -48,7 +51,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['GET'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'show', name: $name, @@ -67,6 +72,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, twigContextFactory: $twigContextFactory, redirectToRoute: $redirectToRoute, vars: $vars, diff --git a/src/Component/src/Metadata/Update.php b/src/Component/src/Metadata/Update.php index 85e393690..28a09f04f 100644 --- a/src/Component/src/Metadata/Update.php +++ b/src/Component/src/Metadata/Update.php @@ -22,7 +22,9 @@ final class Update extends HttpOperation implements UpdateOperationInterface, St public function __construct( ?array $methods = null, ?string $path = null, + ?string $routeName = null, ?string $routePrefix = null, + ?array $routeRequirements = null, ?string $template = null, ?string $shortName = null, ?string $name = null, @@ -41,6 +43,7 @@ public function __construct( ?array $formOptions = null, ?array $validationContext = null, ?string $eventShortName = null, + ?string $notificationMessage = null, string|callable|null $twigContextFactory = null, ?string $redirectToRoute = null, ?array $redirectArguments = null, @@ -52,7 +55,9 @@ public function __construct( parent::__construct( methods: $methods ?? ['GET', 'PUT', 'POST'], path: $path, + routeName: $routeName, routePrefix: $routePrefix, + routeRequirements: $routeRequirements, template: $template, shortName: $shortName ?? 'update', name: $name, @@ -71,6 +76,7 @@ public function __construct( formOptions: $formOptions, validationContext: $validationContext, eventShortName: $eventShortName, + notificationMessage: $notificationMessage, twigContextFactory: $twigContextFactory, redirectToRoute: $redirectToRoute, redirectArguments: $redirectArguments, diff --git a/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactory.php b/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactory.php index 84ad9a2bd..9b55660e9 100644 --- a/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactory.php +++ b/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactory.php @@ -19,10 +19,16 @@ use Sylius\Resource\Metadata\RegistryInterface; use Sylius\Resource\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use Sylius\Resource\Metadata\ResourceMetadata; +use Sylius\Resource\Symfony\Routing\Factory\Resource\ResourceRouteCollectionFactory; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; use Webmozart\Assert\Assert; +trigger_deprecation('sylius/resource', '1.13', '"%s" is deprecated, use "%s" instead.', AttributesOperationRouteFactory::class, ResourceRouteCollectionFactory::class); + +/** + * @deprecated + */ final class AttributesOperationRouteFactory implements AttributesOperationRouteFactoryInterface { public function __construct( diff --git a/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactoryInterface.php b/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactoryInterface.php index e950865df..89745f305 100644 --- a/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactoryInterface.php +++ b/src/Component/src/Symfony/Routing/Factory/AttributesOperationRouteFactoryInterface.php @@ -13,8 +13,14 @@ namespace Sylius\Resource\Symfony\Routing\Factory; +use Sylius\Resource\Symfony\Routing\Factory\Resource\ResourceRouteCollectionFactoryInterface; use Symfony\Component\Routing\RouteCollection; +trigger_deprecation('sylius/resource', '1.13', '"%s" is deprecated, use "%s" instead.', AttributesOperationRouteFactoryInterface::class, ResourceRouteCollectionFactoryInterface::class); + +/** + * @deprecated + */ interface AttributesOperationRouteFactoryInterface { /** @psalm-param class-string $className */ diff --git a/src/Component/src/Symfony/Routing/Factory/OperationRouteFactory.php b/src/Component/src/Symfony/Routing/Factory/OperationRouteFactory.php index 88c09cd4a..15b790574 100644 --- a/src/Component/src/Symfony/Routing/Factory/OperationRouteFactory.php +++ b/src/Component/src/Symfony/Routing/Factory/OperationRouteFactory.php @@ -44,6 +44,7 @@ public function create(MetadataInterface $metadata, ResourceMetadata $resource, '_controller' => 'sylius.main_controller', '_sylius' => $this->getSyliusOptions($resource, $operation), ], + requirements: $operation->getRouteRequirements() ?? [], methods: $operation->getMethods() ?? [], ); } diff --git a/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactory.php b/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactory.php new file mode 100644 index 000000000..ce4319685 --- /dev/null +++ b/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactory.php @@ -0,0 +1,79 @@ +resourceMetadataFactory->create($className); + + /** @var ResourceMetadata $resource */ + foreach ($resourceMetadataCollection->getIterator() as $resource) { + $this->createRoutesForResource($routeCollection, $resource); + } + + return $routeCollection; + } + + private function createRoutesForResource(RouteCollection $routeCollection, ResourceMetadata $resource): void + { + foreach ($resource->getOperations() ?? new Operations() as $operation) { + if (!$operation instanceof HttpOperation) { + continue; + } + + $this->addRouteForOperation($routeCollection, $resource, $operation); + } + } + + private function addRouteForOperation(RouteCollection $routeCollection, ResourceMetadata $resource, HttpOperation $operation): void + { + $metadata = $this->resourceRegistry->get($resource->getAlias() ?? ''); + $routeName = $operation->getRouteName(); + + Assert::notNull($routeName, sprintf('Operation %s has no route name. Please define one.', $operation::class)); + + $route = $this->createRoute($metadata, $resource, $operation); + $routeCollection->add($routeName, $route); + } + + private function createRoute(MetadataInterface $metadata, ResourceMetadata $resource, HttpOperation $operation): Route + { + return $this->operationRouteFactory->create($metadata, $resource, $operation); + } +} diff --git a/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryInterface.php b/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryInterface.php new file mode 100644 index 000000000..e1a44e682 --- /dev/null +++ b/src/Component/src/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryInterface.php @@ -0,0 +1,25 @@ +resourceNameCollectionFactory->create(); + + /** + * @var class-string $resourceName + */ + foreach ($resourceNames as $resourceName) { + $routeCollection->addCollection($this->resourceRouteCollectionFactory->createRouteCollectionForClass($resourceName)); + } + + return $routeCollection; + } +} diff --git a/src/Component/src/Symfony/Session/Flash/FlashHelper.php b/src/Component/src/Symfony/Session/Flash/FlashHelper.php index ca3698b67..770608fb9 100644 --- a/src/Component/src/Symfony/Session/Flash/FlashHelper.php +++ b/src/Component/src/Symfony/Session/Flash/FlashHelper.php @@ -74,7 +74,7 @@ private function buildOperationMessage(Operation $operation, string $type): stri $resource = $operation->getResource(); Assert::notNull($resource); - $key = sprintf('%s.%s.%s', $resource->getApplicationName() ?? '', $resource->getName() ?? '', $operation->getShortName() ?? ''); + $key = $operation->getNotificationMessage() ?? sprintf('%s.%s.%s', $resource->getApplicationName() ?? '', $resource->getName() ?? '', $operation->getShortName() ?? ''); $fallbackKey = sprintf('sylius.resource.%s', $operation->getShortName() ?? ''); $parameters = $this->getTranslationParameters($operation); diff --git a/src/Component/tests/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactoryTest.php b/src/Component/tests/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactoryTest.php new file mode 100644 index 000000000..a760f8cd5 --- /dev/null +++ b/src/Component/tests/Doctrine/ORM/Metadata/Resource/Factory/DoctrineORMResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,123 @@ +managerRegistry = $this->prophesize(ManagerRegistry::class); + $this->decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $this->factory = new DoctrineORMResourceMetadataCollectionFactory( + $this->managerRegistry->reveal(), + $this->decorated->reveal(), + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(DoctrineORMResourceMetadataCollectionFactory::class, $this->factory); + } + + public function testItAddsPersistProcessorToOperationsForResourceManagedByDoctrineOrm(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + + $operation = new Create(name: 'app_dummy_create'); + $resource = (new ResourceMetadata(alias: 'app.dummy')) + ->withOperations(new Operations([$operation])) + ->withClass('App\Dummy') + ; + + $resourceMetadataCollection = new ResourceMetadataCollection([$resource]); + + $this->decorated->create('App\Resource')->willReturn($resourceMetadataCollection); + $this->managerRegistry->getManagerForClass('App\Dummy')->willReturn($entityManager); + + $result = $this->factory->create('App\Resource'); + + $this->assertEquals( + PersistProcessor::class, + $result->getOperation('app.dummy', 'app_dummy_create')->getProcessor(), + ); + } + + public function testItAddsRemoveProcessorToDeleteOperationsForResourceManagedByDoctrineOrm(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + + $operation = new Delete(name: 'app_dummy_delete'); + $resource = (new ResourceMetadata(alias: 'app.dummy')) + ->withOperations(new Operations([$operation])) + ->withClass('App\Dummy') + ; + + $resourceMetadataCollection = new ResourceMetadataCollection([$resource]); + + $this->decorated->create('App\Resource')->willReturn($resourceMetadataCollection); + $this->managerRegistry->getManagerForClass('App\Dummy')->willReturn($entityManager); + + $result = $this->factory->create('App\Resource'); + + $this->assertEquals( + RemoveProcessor::class, + $result->getOperation('app.dummy', 'app_dummy_delete')->getProcessor(), + ); + } + + public function testItDoesNothingWhenResourceIsNotManagedByDoctrineOrm(): void + { + $operation = new Create(name: 'app_dummy_create'); + $resource = (new ResourceMetadata(alias: 'app.dummy')) + ->withOperations(new Operations([$operation])) + ->withClass('App\Dummy') + ; + + $resourceMetadataCollection = new ResourceMetadataCollection([$resource]); + + $this->decorated->create('App\Resource')->willReturn($resourceMetadataCollection); + $this->managerRegistry->getManagerForClass('App\Dummy')->willReturn(null); + + $result = $this->factory->create('App\Resource'); + + $this->assertEquals( + null, + $result->getOperation('app.dummy', 'app_dummy_create')->getProcessor(), + ); + } +} diff --git a/src/Component/tests/Doctrine/ORM/State/ProviderTest.php b/src/Component/tests/Doctrine/ORM/State/ProviderTest.php new file mode 100644 index 000000000..4e39b5f4f --- /dev/null +++ b/src/Component/tests/Doctrine/ORM/State/ProviderTest.php @@ -0,0 +1,116 @@ +managerRegistry = $this->createMock(ManagerRegistry::class); + $this->locator = $this->createMock(ContainerInterface::class); + $this->argumentParser = $this->createMock(ArgumentParserInterface::class); + $this->provider = new Provider($this->managerRegistry, new RepositoryArgumentResolver(), $this->argumentParser, $this->locator); + } + + public function testItCallsRepositoryFromDoctrineManagerRegistry(): void + { + $operation = $this->createMock(Operation::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $unitOfWork = $this->createMock(UnitOfWork::class); + $entityPersister = $this->createMock(EntityPersister::class); + + $operation->method('getRepository')->willReturn(null); + $operation->method('getRepositoryArguments')->willReturn(null); + $operation->method('getResource')->willReturn((new ResourceMetadata())->withClass('App\Dummy')); + + $this->managerRegistry->method('getManagerForClass')->with('App\Dummy')->willReturn($entityManager); + $entityRepository = new EntityRepository($entityManager, new ClassMetadata('App\Dummy')); + $entityManager->method('getRepository')->willReturn($entityRepository); + + $entityManager->method('getUnitOfWork')->willReturn($unitOfWork); + $unitOfWork->method('getEntityPersister')->willReturn($entityPersister); + + $expectedResult = (object) ['id' => 'my_id']; + $entityPersister->method('load')->with(['id' => 'my_id'], null, null, [], null, 1)->willReturn($expectedResult); + + $request = new Request([], [], ['_route_params' => ['id' => 'my_id']]); + + $response = $this->provider->provide($operation, new Context(new RequestOption($request))); + $this->assertEquals($expectedResult, $response); + } + + public function testItCallsRepositoryAsCallable(): void + { + $operation = $this->createMock(Operation::class); + $operation->method('getRepository')->willReturn([RepositoryWithCallables::class, 'find']); + $operation->method('getRepositoryArguments')->willReturn(null); + + $request = new Request([], [], ['_route_params' => ['id' => 'my_id']]); + + $response = $this->provider->provide($operation, new Context(new RequestOption($request))); + $this->assertInstanceOf(\stdClass::class, $response); + $this->assertEquals('my_id', $response->id); + } + + public function testItCallsRepositoryAsString(): void + { + $operation = $this->createMock(Operation::class); + $operation->method('getRepository')->willReturn('App\\Repository'); + $operation->method('getRepositoryMethod')->willReturn(null); + $operation->method('getRepositoryArguments')->willReturn(null); + + $request = new Request([], [], ['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]); + + $repository = $this->createMock(RepositoryInterface::class); + $stdClass = new \stdClass(); + + $this->locator->method('has')->with('App\\Repository')->willReturn(true); + $this->locator->method('get')->with('App\\Repository')->willReturn($repository); + $repository->method('findOneBy')->with(['id' => 'my_id'])->willReturn($stdClass); + + $response = $this->provider->provide($operation, new Context(new RequestOption($request))); + $this->assertSame($stdClass, $response); + } +} diff --git a/src/Component/tests/Dummy/DummyResource.php b/src/Component/tests/Dummy/DummyResource.php new file mode 100644 index 000000000..2e039f637 --- /dev/null +++ b/src/Component/tests/Dummy/DummyResource.php @@ -0,0 +1,21 @@ +assertInstanceOf(Index::class, $operation); $this->assertSame('app_order_index', $operation->getName()); $this->assertSame(['GET'], $operation->getMethods()); - $this->assertSame('app.repository.order', $operation->getRepository()); $this->assertSame('App\Form\OrderType', $operation->getFormType()); $operation = $metadataCollection->getOperation('app.cart', 'app_cart_index'); $this->assertInstanceOf(Index::class, $operation); $this->assertSame('app_cart_index', $operation->getName()); $this->assertSame(['GET'], $operation->getMethods()); - $this->assertSame('app.repository.cart', $operation->getRepository()); $this->assertSame('App\Form\CartType', $operation->getFormType()); $operation = $metadataCollection->getOperation('app.cart', 'app_cart_show'); $this->assertInstanceOf(Show::class, $operation); $this->assertSame('app_cart_show', $operation->getName()); $this->assertSame(['GET'], $operation->getMethods()); - $this->assertSame('app.repository.cart', $operation->getRepository()); $this->assertSame('App\Form\CartType', $operation->getFormType()); } diff --git a/src/Component/tests/Metadata/Resource/Factory/AttributesResourceNameCollectionFactoryTest.php b/src/Component/tests/Metadata/Resource/Factory/AttributesResourceNameCollectionFactoryTest.php new file mode 100644 index 000000000..151110b83 --- /dev/null +++ b/src/Component/tests/Metadata/Resource/Factory/AttributesResourceNameCollectionFactoryTest.php @@ -0,0 +1,34 @@ + [dirname(__DIR__, 3) . '/Dummy']], + ); + + $collection = $attributesResourceNameCollectionFactory->create(); + + $this->assertContains(DummyResource::class, $collection->getIterator()); + $this->assertNotContains(PullRequest::class, $collection->getIterator()); + } +} diff --git a/src/Component/tests/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactoryTest.php b/src/Component/tests/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactoryTest.php index ff76e9c57..6e777f28c 100644 --- a/src/Component/tests/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactoryTest.php +++ b/src/Component/tests/Metadata/Resource/Factory/ProviderResourceMetadataCollectionFactoryTest.php @@ -23,7 +23,6 @@ use Sylius\Resource\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use Sylius\Resource\Metadata\Resource\ResourceMetadataCollection; use Sylius\Resource\Metadata\ResourceMetadata; -use Sylius\Resource\Symfony\Request\State\Provider; final class ProviderResourceMetadataCollectionFactoryTest extends TestCase { @@ -44,27 +43,6 @@ public function testItIsInitializable(): void $this->assertInstanceOf(ProviderResourceMetadataCollectionFactory::class, $this->factory); } - public function testItCreatesResourceMetadataWithDefaultProviderOnHttpOperations(): void - { - $resource = new ResourceMetadata(alias: 'app.book', name: 'book', applicationName: 'app'); - - $index = (new Index(name: 'app_book_index'))->withResource($resource); - - $resource = $resource->withOperations(new Operations([ - $index->getName() => $index, - ])); - - $resourceMetadataCollection = new ResourceMetadataCollection(); - $resourceMetadataCollection[] = $resource; - - $this->decorated->create('App\Resource')->willReturn($resourceMetadataCollection); - - $resourceMetadataCollection = $this->factory->create('App\Resource'); - - $index = $resourceMetadataCollection->getOperation('app.book', 'app_book_index'); - $this->assertSame(Provider::class, $index->getProvider()); - } - public function testItConfiguresRequestGridProviderIfOperationHasAGrid(): void { $resource = new ResourceMetadata(alias: 'app.book', name: 'book', applicationName: 'app'); diff --git a/src/Component/tests/Metadata/Resource/ResourceNameCollectionTest.php b/src/Component/tests/Metadata/Resource/ResourceNameCollectionTest.php new file mode 100644 index 000000000..c482e6557 --- /dev/null +++ b/src/Component/tests/Metadata/Resource/ResourceNameCollectionTest.php @@ -0,0 +1,43 @@ +assertInstanceOf(\IteratorAggregate::class, $collection); + } + + public function testItIsCountable(): void + { + $collection = new ResourceNameCollection(); + + $this->assertInstanceOf(\Countable::class, $collection); + } + + public function testItIsACollectionOfResourceNames(): void + { + $collection = new ResourceNameCollection(['first_resource', 'second_resource']); + + $this->assertCount(2, $collection); + $this->assertEquals('first_resource', $collection->getIterator()[0]); + $this->assertEquals('second_resource', $collection->getIterator()[1]); + } +} diff --git a/src/Component/tests/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryTest.php b/src/Component/tests/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryTest.php new file mode 100644 index 000000000..a7768a413 --- /dev/null +++ b/src/Component/tests/Symfony/Routing/Factory/Resource/ResourceRouteCollectionFactoryTest.php @@ -0,0 +1,71 @@ +resourceRegistry = $this->createMock(RegistryInterface::class); + $this->routePathFactory = $this->createMock(OperationRoutePathFactoryInterface::class); + + $this->factory = new ResourceRouteCollectionFactory( + new OperationRouteFactory($this->routePathFactory), + new AttributesResourceMetadataCollectionFactory( + $this->resourceRegistry, + new OperationRouteNameFactory(), + ), + $this->resourceRegistry, + ); + } + + public function testItCreatesRoutesWithOperations(): void + { + $metadata = $this->createMock(MetadataInterface::class); + $metadata->method('getServiceId')->with('repository')->willReturn('app.repository.dummy'); + $metadata->method('getClass')->willReturnMap([ + ['form', 'App\Form'], + ['model', 'App\Dummy'], + ]); + $metadata->method('getApplicationName')->willReturn('app'); + $metadata->method('getName')->willReturn('dummy'); + $metadata->method('getPluralName')->willReturn('dummies'); + + $this->resourceRegistry->method('get')->with('app.dummy')->willReturn($metadata); + + $routeCollection = $this->factory->createRouteCollectionForClass(DummyResourceWithOperations::class); + + $this->assertEquals(4, $routeCollection->count()); + $this->assertNotNull($routeCollection->get('app_dummy_index'), 'Route "app_dummy_index" not found but it should.'); + $this->assertNotNull($routeCollection->get('app_dummy_create'), 'Route "app_dummy_create" not found but it should.'); + $this->assertNotNull($routeCollection->get('app_dummy_update'), 'Route "app_dummy_update" not found but it should.'); + $this->assertNotNull($routeCollection->get('app_dummy_show'), 'Route "app_dummy_show" not found but it should.'); + } +} diff --git a/src/Component/tests/Symfony/Routing/Loader/ResourceLoaderTest.php b/src/Component/tests/Symfony/Routing/Loader/ResourceLoaderTest.php new file mode 100644 index 000000000..3846a2c97 --- /dev/null +++ b/src/Component/tests/Symfony/Routing/Loader/ResourceLoaderTest.php @@ -0,0 +1,62 @@ +resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $this->resourceRouteCollectionFactory = $this->createMock(ResourceRouteCollectionFactoryInterface::class); + + $this->loader = new ResourceLoader( + $this->resourceNameCollectionFactory, + $this->resourceRouteCollectionFactory, + ); + } + + public function testItIsARouteLoader(): void + { + $this->assertInstanceOf(RouteLoaderInterface::class, $this->loader); + } + + public function testItGeneratesRoutesFromResource(): void + { + $routeCollection = new RouteCollection(); + $routeCollection->add('first_route', new Route('/first-route')); + $routeCollection->add('second_route', new Route('/second-route')); + + $resourceNameCollection = new ResourceNameCollection(['\DummyClass']); + + $this->resourceNameCollectionFactory->method('create')->willReturn($resourceNameCollection); + $this->resourceRouteCollectionFactory->method('createRouteCollectionForClass')->with('\DummyClass')->willReturn($routeCollection); + + $this->assertEquals($routeCollection, ($this->loader)()); + } +} diff --git a/tests/Application/config/packages/doctrine.yaml b/tests/Application/config/packages/doctrine.yaml index 5c208b673..1ab04b5e0 100644 --- a/tests/Application/config/packages/doctrine.yaml +++ b/tests/Application/config/packages/doctrine.yaml @@ -19,6 +19,11 @@ doctrine: type: attribute dir: '%kernel.project_dir%/src/BoardGameBlog/Domain' prefix: 'App\BoardGameBlog\Domain' + Conference: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/Conference/Entity' + prefix: 'App\Conference\Entity' Subscription: is_bundle: false type: attribute diff --git a/tests/Application/config/routes/sylius_resource.yaml b/tests/Application/config/routes/sylius_resource.yaml index 0c8ade2c2..04dfe3569 100644 --- a/tests/Application/config/routes/sylius_resource.yaml +++ b/tests/Application/config/routes/sylius_resource.yaml @@ -1,7 +1,11 @@ sylius_crud_routes: resource: 'sylius.routing.loader.crud_routes_attributes' type: service +# +#sylius_routes: +# resource: 'sylius.routing.loader.routes_attributes' +# type: service -sylius_routes: - resource: 'sylius.routing.loader.routes_attributes' +sylius_resource_routes: + resource: 'sylius_resource.symfony.routing.loader.resource' type: service diff --git a/tests/Application/config/services.yaml b/tests/Application/config/services.yaml index e688256a7..b41a36298 100644 --- a/tests/Application/config/services.yaml +++ b/tests/Application/config/services.yaml @@ -99,9 +99,6 @@ services: App\Subscription\: resource: '../src/Subscription' - App\Subscription\Factory\SubscriptionFactory: - decorates: 'app.factory.subscription' - app.service.legacy_autowired_repository: class: App\Service\LegacyAutowiredRepositoryService autowire: true diff --git a/tests/Application/config/sylius/resources.yaml b/tests/Application/config/sylius/resources.yaml index e4f291993..08b41c935 100644 --- a/tests/Application/config/sylius/resources.yaml +++ b/tests/Application/config/sylius/resources.yaml @@ -1,5 +1,7 @@ sylius_resource: mapping: + imports: + - '%kernel.project_dir%/config/sylius/resources' paths: - '%kernel.project_dir%/src/BoardGameBlog/Infrastructure/Sylius/Resource' - '%kernel.project_dir%/src/Subscription/Entity' @@ -61,3 +63,7 @@ sylius_resource: classes: model: App\Entity\Zone\ZoneMember interface: App\Entity\Zone\ZoneMemberInterface + + app.speaker: + classes: + model: App\Conference\Entity\Speaker diff --git a/tests/Application/config/sylius/resources/custom_speaker_create.php b/tests/Application/config/sylius/resources/custom_speaker_create.php new file mode 100644 index 000000000..c075807e5 --- /dev/null +++ b/tests/Application/config/sylius/resources/custom_speaker_create.php @@ -0,0 +1,26 @@ +getName() + ) { + return $operation; + } + + return $operation->withPath('speakers/register'); +}; diff --git a/tests/Application/config/sylius/resources/speaker.php b/tests/Application/config/sylius/resources/speaker.php new file mode 100644 index 000000000..660c1316d --- /dev/null +++ b/tests/Application/config/sylius/resources/speaker.php @@ -0,0 +1,29 @@ +withRoutePrefix('/admin') + ->withClass(Speaker::class) + ->withSection('admin') + ->withTemplatesDir('crud') + ->withOperations(new Operations([ + new Create(), + new Index(), + ])) +; diff --git a/tests/Application/config/sylius/resources/speaker_update.php b/tests/Application/config/sylius/resources/speaker_update.php new file mode 100644 index 000000000..5743ce6bd --- /dev/null +++ b/tests/Application/config/sylius/resources/speaker_update.php @@ -0,0 +1,27 @@ +withRoutePrefix('/admin') + ->withClass(Speaker::class) + ->withSection('admin') + ->withTemplatesDir('crud') + ->withOperations(new Operations([ + new Update(), + ])) +; diff --git a/tests/Application/src/Conference/Entity/Speaker.php b/tests/Application/src/Conference/Entity/Speaker.php new file mode 100644 index 000000000..3be181e85 --- /dev/null +++ b/tests/Application/src/Conference/Entity/Speaker.php @@ -0,0 +1,41 @@ +id; + } +} diff --git a/tests/Application/src/Subscription/Entity/Subscription.php b/tests/Application/src/Subscription/Entity/Subscription.php index 1e2aa6e50..e721ece8e 100644 --- a/tests/Application/src/Subscription/Entity/Subscription.php +++ b/tests/Application/src/Subscription/Entity/Subscription.php @@ -13,7 +13,9 @@ namespace App\Subscription\Entity; +use App\Subscription\Factory\SubscriptionFactory; use App\Subscription\Form\Type\SubscriptionType; +use App\Subscription\Repository\SubscriptionRepository; use App\Subscription\Twig\Context\Factory\ShowSubscriptionContextFactory; use Doctrine\ORM\Mapping as ORM; use Sylius\Resource\Metadata\Api; @@ -35,9 +37,10 @@ formType: SubscriptionType::class, templatesDir: 'crud', routePrefix: '/admin', + driver: false, )] #[Index(grid: 'app_subscription')] -#[Create] +#[Create(factory: [SubscriptionFactory::class, 'createNew'])] #[Update] #[Delete] #[BulkDelete] @@ -59,6 +62,7 @@ routePrefix: '/ajax', normalizationContext: ['groups' => 'subscription:read'], denormalizationContext: ['groups' => 'subscription:write'], + driver: false, )] #[Api\GetCollection] #[Api\Post] @@ -66,7 +70,7 @@ #[Api\Delete] #[Api\Get] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: SubscriptionRepository::class)] class Subscription implements ResourceInterface { #[ORM\Column(type: 'string')] diff --git a/tests/Application/src/Subscription/Factory/SubscriptionFactory.php b/tests/Application/src/Subscription/Factory/SubscriptionFactory.php index 01c35ffb1..230c3a7c1 100644 --- a/tests/Application/src/Subscription/Factory/SubscriptionFactory.php +++ b/tests/Application/src/Subscription/Factory/SubscriptionFactory.php @@ -14,11 +14,10 @@ namespace App\Subscription\Factory; use App\Subscription\Entity\Subscription; -use Sylius\Resource\Factory\FactoryInterface; -final class SubscriptionFactory implements FactoryInterface +final class SubscriptionFactory { - public function createNew(): Subscription + public static function createNew(): Subscription { return new Subscription(email: 'new@example.com'); } diff --git a/tests/Application/src/Subscription/Repository/SubscriptionRepository.php b/tests/Application/src/Subscription/Repository/SubscriptionRepository.php new file mode 100644 index 000000000..ca5b9ed45 --- /dev/null +++ b/tests/Application/src/Subscription/Repository/SubscriptionRepository.php @@ -0,0 +1,29 @@ +assertResponseRedirects(null, expectedCode: Response::HTTP_FOUND); - /** @var Subscription $subscription */ - $subscription = static::getContainer()->get('app.repository.subscription')->findOneBy(['email' => 'biff.tannen@bttf.com']); + $subscription = $this->getSubscriptionRepository()->findOneBy(['email' => 'biff.tannen@bttf.com']); $this->assertNotNull($subscription); $this->assertSame('biff.tannen@bttf.com', (string) $subscription->email); @@ -180,8 +181,7 @@ public function it_allows_deleting_a_subscription(): void $this->assertResponseRedirects(null, expectedCode: Response::HTTP_FOUND); - /** @var Subscription[] $subscriptions */ - $subscriptions = static::getContainer()->get('app.repository.subscription')->findAll(); + $subscriptions = $this->getSubscriptionRepository()->findAll(); $this->assertEmpty($subscriptions); } @@ -196,8 +196,7 @@ public function it_allows_deleting_multiple_subscriptions(): void $this->assertResponseRedirects(null, expectedCode: Response::HTTP_FOUND); - /** @var Subscription[] $subscriptions */ - $subscriptions = static::getContainer()->get('app.repository.subscription')->findAll(); + $subscriptions = $this->getSubscriptionRepository()->findAll(); $this->assertEmpty($subscriptions); } @@ -240,4 +239,12 @@ protected function buildMatcher(): Matcher { return $this->matcherFactory->createMatcher(new VoidBacktrace()); } + + /** + * @return ObjectRepository + */ + private function getSubscriptionRepository(): ObjectRepository + { + return static::getContainer()->get(EntityManagerInterface::class)->getRepository(Subscription::class); + } } diff --git a/tests/Bundle/Command/DebugResourceCommandTest.php b/tests/Bundle/Command/DebugResourceCommandTest.php index 618c684c4..aaeb12b8d 100644 --- a/tests/Bundle/Command/DebugResourceCommandTest.php +++ b/tests/Bundle/Command/DebugResourceCommandTest.php @@ -283,6 +283,7 @@ public function it_displays_the_metadata_for_given_resource_operation(): void path null routeName null routePrefix null + routeRequirements null redirectToRoute null redirectArguments null vars [ @@ -308,6 +309,7 @@ public function it_displays_the_metadata_for_given_resource_operation(): void denormalizationContext null validationContext null eventShortName "register" + notificationMessage null ------------------------ -------------------------- diff --git a/tests/Bundle/Configuration/ConfigurationTest.php b/tests/Bundle/Configuration/ConfigurationTest.php index 54865c590..0ba04f7ed 100644 --- a/tests/Bundle/Configuration/ConfigurationTest.php +++ b/tests/Bundle/Configuration/ConfigurationTest.php @@ -72,6 +72,7 @@ public function it_has_no_default_mapping_paths(): void [ 'mapping' => [ 'paths' => [], + 'imports' => [], ], ], 'mapping', @@ -92,6 +93,7 @@ public function its_mapping_paths_can_be_customized(): void 'paths' => [ 'path/to/resources', ], + 'imports' => [], ], ], 'mapping', diff --git a/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php b/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php index 11a4e2c50..021631cc4 100644 --- a/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php +++ b/tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php @@ -126,6 +126,7 @@ public function it_registers_parameter_for_paths(): void 'paths' => [ __DIR__ . '/Dummy', ], + 'imports' => [], ]); }