diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..68c4f70 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + run: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.2' + - '8.3' + symfony-versions: + - '^6.4' + - '^7.0' + + name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} ${{ matrix.description }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions/cache@v2 + with: + path: ~/.composer/cache/files + key: ${{ matrix.php }}-${{ matrix.symfony-versions }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.coverage }} + + - name: Add PHPUnit matcher + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Set composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer + uses: actions/cache@v2.1.2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer + + - name: Update Symfony version + if: matrix.symfony-versions != '' + run: | + composer require symfony/form:${{ matrix.symfony-versions }} --no-update --no-scripts + composer require symfony/framework-bundle:${{ matrix.symfony-versions }} --no-update --no-scripts + composer require symfony/validator:${{ matrix.symfony-versions }} --no-update --no-scripts + composer require --dev symfony/yaml:${{ matrix.symfony-versions }} --no-update --no-scripts + + - name: Install dependencies + run: composer install + + - name: Run PHPUnit tests + run: vendor/bin/phpunit diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml new file mode 100644 index 0000000..ddd5fe3 --- /dev/null +++ b/.github/workflows/static-analysis.yaml @@ -0,0 +1,55 @@ +name: Code style and static analysis + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpcs + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: vendor/bin/phpstan + + composer-validate: + name: Composer validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94f11aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/.DS_Store +.idea/* +.phpcs-cache +composer.lock +phpunit.xml +var/ +vendor/ diff --git a/README.md b/README.md index cc54573..07a7bc8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ -# request-dto-resolver -Symfony request resolver bundle +Symfony Request DTO Resolver bundle +=============================== +Automatically parses Symfony HTTP request, validates parameters, hydrates DTO and passes it as an argument +to your controller. + +Installation +------------ + +Open a command console, switch to your project directory and execute: + +```console +composer require macpaw/request-dto-resolver +``` +Your bundle now should be automatically added to the list of registered bundles. + +```php +// config/bundles.php + ['all' => true], + + // ... + ]; +``` +If your application doesn't use Symfony Flex you need to manually add your bundle +to the list of registered bundles in `config/bundles.php` file. + + +Create bundle config +-------------------- + +```yaml +# config/packages/request_dto_resolver.yaml` +request_dto_resolver: + target_dto_interface: +``` +You need to specify the interface which your target controller argument implements. +See tests for example. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ec0f705 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "macpaw/request-dto-resolver", + "description": "Request DTO resolver bundle", + "homepage": "https://github.com/MacPaw/request-dto-resolver", + "type": "symfony-bundle", + "keywords": [ + "request", + "dto", + "resolver" + ], + "license": "MIT", + "require": { + "php": ">=8.0", + "symfony/form": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" + }, + "require-dev": { + "escapestudios/symfony2-coding-standard": "3.x-dev", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^10.0", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/yaml": "^6.4|^7.0" + }, + "autoload": { + "psr-4": { + "RequestDtoResolver\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "RequestDtoResolver\\Tests\\": "tests" + } + }, + "scripts": { + "cs": [ + "vendor/bin/phpcs" + ], + "cs-fix": [ + "vendor/bin/phpcbf" + ], + "phpstan": [ + "vendor/bin/phpstan" + ], + "phpunit": [ + "vendor/bin/phpunit" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..d156222 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + src/ + tests/ + */Resources/* + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b3bea42 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: 5 + paths: + - src + - tests + ignoreErrors: + - + message: '~Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).~' + count: 1 + path: ./src/DependencyInjection diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..e8d60b9 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + tests/Integration + + + + + ./src + + + ./src + + + diff --git a/src/Attribute/FormType.php b/src/Attribute/FormType.php new file mode 100644 index 0000000..bba4421 --- /dev/null +++ b/src/Attribute/FormType.php @@ -0,0 +1,21 @@ +class; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..2929df0 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,23 @@ +getRootNode() + ->children() + ->scalarNode('target_dto_interface')->cannotBeEmpty()->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/RequestDtoResolverExtension.php b/src/DependencyInjection/RequestDtoResolverExtension.php new file mode 100644 index 0000000..66f2f7e --- /dev/null +++ b/src/DependencyInjection/RequestDtoResolverExtension.php @@ -0,0 +1,22 @@ +load('services.yaml'); + + $config = $this->processConfiguration(new Configuration(), $configs); + $container->setParameter('request_dto_resolver.target_dto_interface', $config['target_dto_interface']); + } +} diff --git a/src/Exception/InvalidParamsDtoException.php b/src/Exception/InvalidParamsDtoException.php new file mode 100644 index 0000000..b048dc0 --- /dev/null +++ b/src/Exception/InvalidParamsDtoException.php @@ -0,0 +1,24 @@ +dtoClassName = $dtoClassName; + + parent::__construct($list); + } + + public function getDtoClassName(): string + { + return $this->dtoClassName; + } +} diff --git a/src/Exception/InvalidParamsException.php b/src/Exception/InvalidParamsException.php new file mode 100644 index 0000000..f45e9fb --- /dev/null +++ b/src/Exception/InvalidParamsException.php @@ -0,0 +1,25 @@ +list = $list; + + parent::__construct('Params not valid'); + } + + public function getList(): ConstraintViolationListInterface + { + return $this->list; + } +} diff --git a/src/RequestDtoResolverBundle.php b/src/RequestDtoResolverBundle.php new file mode 100644 index 0000000..a8e40c3 --- /dev/null +++ b/src/RequestDtoResolverBundle.php @@ -0,0 +1,11 @@ +getType(); + + if ($dtoClass === null || !is_subclass_of($dtoClass, $this->targetDtoInterface)) { + return []; + } + + /** @var string $controllerClass */ + $controllerClass = $request->attributes->get('_controller'); + $formType = $this->getFormType($controllerClass); + + $form = $this->formFactory->create($formType); + + $params = []; + foreach ($form->all() as $key => $value) { + $params[$key] = $request->get($key); + if ($params[$key] === null) { + $params[$key] = $request->headers->get($key); + } + } + + $form->submit($params); + + if (!$form->isValid()) { + $constraintViolationList = new ConstraintViolationList(); + + foreach ($form->getErrors(true) as $error) { + if ($error->getCause() instanceof ConstraintViolationInterface) { + $constraintViolationList->add($error->getCause()); + } + } + + throw new InvalidParamsDtoException($constraintViolationList, $dtoClass); + } + + return [$form->getData()]; + } + + private function getFormType(string $controllerClass): string + { + $reflection = new ReflectionClass($controllerClass); + + $attributes = $reflection->getMethod('__invoke')->getAttributes(FormType::class); + + if (count($attributes) <= 0) { + throw new Exception('No FormType argument is specified for controller method'); + } + + /** @var FormType $attribute */ + $attribute = $attributes[0]->newInstance(); + + return $attribute->getClass(); + } +} diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml new file mode 100644 index 0000000..23b938b --- /dev/null +++ b/src/Resources/config/services.yaml @@ -0,0 +1,11 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: true + + RequestDtoResolver\Resolver\RequestDtoResolver: + arguments: + $targetDtoInterface: '%request_dto_resolver.target_dto_interface%' + tags: + - {name: controller.argument_value_resolver, priority: 150} diff --git a/tests/AbstractKernelTestCase.php b/tests/AbstractKernelTestCase.php new file mode 100644 index 0000000..e55951f --- /dev/null +++ b/tests/AbstractKernelTestCase.php @@ -0,0 +1,28 @@ +getContainer()->get('test.service_container'); + + return $container; + } +} diff --git a/tests/Fixture/TargetDtoInterface.php b/tests/Fixture/TargetDtoInterface.php new file mode 100644 index 0000000..8a3e9c0 --- /dev/null +++ b/tests/Fixture/TargetDtoInterface.php @@ -0,0 +1,9 @@ +add('foo', TextType::class, [ + 'required' => true, + 'invalid_message' => 'invalidFoo', + 'constraints' => [ + new Assert\NotBlank(message: 'notBlank'), + ] + ]) + ->add('bar', TextType::class, [ + 'required' => true, + 'invalid_message' => 'invalidBar', + 'constraints' => [ + new Assert\NotBlank(message: 'notBlank'), + ] + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => TestDto::class + ]); + } +} diff --git a/tests/Fixture/config/framework.yaml b/tests/Fixture/config/framework.yaml new file mode 100644 index 0000000..f76cc2e --- /dev/null +++ b/tests/Fixture/config/framework.yaml @@ -0,0 +1,2 @@ +framework: + test: true diff --git a/tests/Fixture/config/request_dto_resolver.yaml b/tests/Fixture/config/request_dto_resolver.yaml new file mode 100644 index 0000000..9bbb6c6 --- /dev/null +++ b/tests/Fixture/config/request_dto_resolver.yaml @@ -0,0 +1,2 @@ +request_dto_resolver: + target_dto_interface: 'RequestDtoResolver\Tests\Fixture\TargetDtoInterface' diff --git a/tests/Fixture/config/services.yaml b/tests/Fixture/config/services.yaml new file mode 100644 index 0000000..cdcbd8e --- /dev/null +++ b/tests/Fixture/config/services.yaml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: true + + RequestDtoResolver\Resolver\RequestDtoResolver: + arguments: + $targetDtoInterface: '%request_dto_resolver.target_dto_interface%' diff --git a/tests/Integration/DependencyInjection/ConfigurationTest.php b/tests/Integration/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..a289e0b --- /dev/null +++ b/tests/Integration/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,26 @@ + 'RequestDtoResolver\Tests\Fixture\TargetDtoInterface', + ]; + + $processor = new Processor(); + $configs = $processor->processConfiguration(new Configuration(), [ + 'request_dto_resolver' => $expectedConfig, + ]); + + $this->assertSame($expectedConfig, $configs); + } +} diff --git a/tests/Integration/DependencyInjection/RequestDtoResolverExtensionTest.php b/tests/Integration/DependencyInjection/RequestDtoResolverExtensionTest.php new file mode 100644 index 0000000..7c2423e --- /dev/null +++ b/tests/Integration/DependencyInjection/RequestDtoResolverExtensionTest.php @@ -0,0 +1,31 @@ + [ + 'target_dto_interface' => 'RequestDtoResolver\Tests\Fixture\TargetDtoInterface', + ], + ]; + + $container = new ContainerBuilder(); + $extension = new RequestDtoResolverExtension(); + $extension->load($configs, $container); + + $this->assertTrue($container->hasParameter('request_dto_resolver.target_dto_interface')); + $this->assertEquals(TargetDtoInterface::class, $container->getParameter( + 'request_dto_resolver.target_dto_interface' + )); + } +} diff --git a/tests/Integration/Resolver/RequestDtoResolverTest.php b/tests/Integration/Resolver/RequestDtoResolverTest.php new file mode 100644 index 0000000..85bfa59 --- /dev/null +++ b/tests/Integration/Resolver/RequestDtoResolverTest.php @@ -0,0 +1,53 @@ +requestDtoResolver = self::getContainer()->get(RequestDtoResolver::class); + } + + public function testResolve(): void + { + $argumentMock = $this->createMock(ArgumentMetadata::class); + $argumentMock->method('getType')->willReturn(TestDto::class); + + $request = new Request(); + $request->attributes->set('_controller', TestController::class); + $request->request->set('foo', 'abc'); + $request->request->set('bar', 'def'); + + $resolved = $this->requestDtoResolver->resolve($request, $argumentMock); + + $this->assertInstanceOf(TargetDtoInterface::class, $resolved[0]); + } + + public function testResolveException(): void + { + $argumentMock = $this->createMock(ArgumentMetadata::class); + $argumentMock->method('getType')->willReturn(TestDto::class); + + $request = new Request(); + $request->attributes->set('_controller', TestController::class); + $request->request->set('foo', 5); + $request->request->set('bar', false); + + $this->expectException(InvalidParamsDtoException::class); + $this->requestDtoResolver->resolve($request, $argumentMock); + } +} diff --git a/tests/TestKernel.php b/tests/TestKernel.php new file mode 100644 index 0000000..296e5b8 --- /dev/null +++ b/tests/TestKernel.php @@ -0,0 +1,31 @@ +load(__DIR__ . '/Fixture/config/framework.yaml'); + $loader->load(__DIR__ . '/Fixture/config/request_dto_resolver.yaml'); + $loader->load(__DIR__ . '/Fixture/config/services.yaml'); + } +}