diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d12ca5a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +{ + "version": 2, + "updates": [ + { + "allow": [ + { + "dependency-type": "all" + } + ], + "directory": "/", + "package-ecosystem": "composer", + "schedule": { + "interval": "weekly" + }, + "versioning-strategy": "widen" + }, + { + "directory": "/", + "package-ecosystem": "github-actions", + "schedule": { + "interval": "weekly" + } + }, + ] +} \ No newline at end of file diff --git a/.github/workflows/php.yml b/.github/workflows/checks.yml similarity index 79% rename from .github/workflows/php.yml rename to .github/workflows/checks.yml index 77c4add..292381e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/checks.yml @@ -1,14 +1,6 @@ -name: PHP Composer - -on: - push: - branches: - - master - - wip/* - pull_request: - branches: - - master - - wip/* +name: Checks + +on: push permissions: contents: read @@ -37,7 +29,7 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run PHPStan - run: composer phpstan + run: vendor/bin/phpstan analyze - name: Run test suite run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 769cfe8..2feba0e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ # Composer vendor/ composer.lock + +# PHPUnit +.phpunit.cache + +# PHPCSFixer +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7e8fb23 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,44 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->notPath(__DIR__ . '/src/Resources/config/services.php') + ->notPath(__DIR__ . '/src/DependencyInjection/Configuration.php') +; + +$config = new PhpCsFixer\Config(); +return $config + ->setRiskyAllowed(true) + ->setRules([ + '@Symfony' => true, + 'array_indentation' => true, + 'method_chaining_indentation' => true, + 'no_useless_else' => true, + 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], + 'global_namespace_import' => true, + 'braces' => true, + 'indentation_type' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '=>' => 'align_single_space_minimal', + ], + ], + 'yoda_style' => [ + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + 'concat_space' => [ + 'spacing' => 'one', + ], + 'no_unreachable_default_argument_value' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'strict_comparison' => true, + 'strict_param' => true, + ]) + ->setIndent(str_pad('', 4)) + ->setFinder( + $finder + ); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c0454fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog +This library follows [semantic versioning](https://semver.org). + +See the [releases pages](https://github.com/artyuum/request-dto-mapper-bundle/releases). + +## v0.0.2 +Allowed PHP 8.0 in composer.json + +## v0.0.1 +First release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..759bd55 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,2 @@ +# Contributing +If you'd like to contribute, please fork the repository and make changes as you'd like. Be sure to follow the same coding style & naming used in this library to produce a consistent code. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe19a6e --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021-present Dynèsh HASSANALY + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 6801875..819b01e 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,226 @@ # Request DTO Mapper Bundle -This bundle provides an easy way to automatically map the incoming request data to a DTO and optionally validate it. +![image](https://user-images.githubusercontent.com/17199757/193117824-e5eec5b6-f4c0-4c96-af9b-fa2bc6096806.png) + +This bundle provides an easy way to automatically map the incoming request data to a DTO and optionally validate it. It's using the powerful [Serializer](https://symfony.com/doc/current/components/serializer.html) component under the hood along with the [Validator](https://symfony.com/doc/current/components/validator.html) component (optional). + +## Requirements +- PHP ^8.0 +- Symfony ^5.0 or ^6.0 ## Installation -``` -composer require artyuum/request-dto-mapper-bundle +```bash +composer require artyuum/request-dto-mapper-bundle ``` ## Configuration -This is the default configuration (`config/packages/artyuum_request_dto_mapper_bundle.yaml`): ```yml -artyuum_request_dto_mapper_bundle: - enabled: true # whether to enable/disable the argument resolver +# config/packages/artyum_request_dto_mapper.yaml +artyum_request_dto_mapper: + + # Used if the attribute does not specify any (must be a FQCN implementing "\Artyum\RequestDtoMapperBundle\Extractor\ExtractorInterface"). + default_extractor: null # Example: Artyum\RequestDtoMapperBundle\Extractor\JsonExtractor + + # The configuration related to the denormalizer (https://symfony.com/doc/current/components/serializer.html). + denormalizer: + + # Used when mapping the request data to the DTO if the attribute does not set any. + default_options: [] + + # Used when mapping the request data to the DTO (merged with the values passed by the attribute or "default_options"). + additional_options: [] + + # The configuration related to the validator (https://symfony.com/doc/current/validation.html). + validation: + + # Whether to validate the DTO after mapping it. + enabled: false + + # Used when validating the DTO if the attribute does not set any. + default_groups: [] + + # Used when validating the DTO (merged with the values passed by the attribute or "default_groups"). + additional_groups: [] + + # Whether to throw an exception if the DTO validation failed (constraint violations). + throw_on_violation: true ``` ## Usage -This is a simple example of how to make a DTO that will be used by the bundle: +This is a simple step-by-step example of how to make a DTO that will be used by the bundle. + +1. Create the DTO that represents the structure of the content the user will send to your controller. ```php -/** - * @Dto(methods={"POST"}, source="json", validationGroups={"create"}) - * @Dto(methods={"PATCH"}, source="json", validationGroups={"edit"}) - */ -class ArtistPayload implements DtoInterface { +class PostPayload { /** * @Assert\Sequentially({ - * @Assert\NotBlank(groups={"create"}), + * @Assert\NotBlank, * @Assert\Type("string") - * }, groups={"create", "edit"}) + * }) * - * @var string|null should contain the artist's name + * @var string|null */ - public $name = null; + public $content; } ``` -Your DTOs **must** implement the [`DtoInterface`](/src/Mapper/DtoInterface.php) and define the request context using the `@Dto` annotation. +2. Inject the DTO into your controller & configure it using the [Dto attribute](/src/Attribute/Dto.php). -Here is the list of options used in the `@Dto` annotation: -| name | type | default | required | description | -|----------------------|---------|---------|----------|-------------------------------------------------------------------------------------------------------------------| -| `methods` | array | - | **yes** | The HTTP method(s) on which to apply the options below. | -| `source` | string | - | **yes** | The source from where the data will be extracted from the request. (`json`, `query_strings` or `body_parameters`) | -| `validation` | boolean | *true* | no | Whether or not to validate the DTO before passing it to the controller. | -| `validationGroups` | array | *null* | no | The validation groups to be used when validating the DTO. | +```php +use Artyum\RequestDtoMapperBundle\Attribute\Dto; +use Artyum\RequestDtoMapperBundle\Extractor\JsonExtractor; -## Events -- **[PreDtoMappingEvent](/src/Event/PreDtoMappingEvent.php)** - disptached before the mapping is made, this allows you to alter the Serializer/Denormalizer options, or the Request object. -- **[PreDtoValidationEvent](/src/Event/PreDtoValidationEvent.php)** - dispatched before the validation is made, this allows you to alter the DTO object (if the validation is enabled). -- **[PostDtoMappingEvent](/src/Event/PostDtoMappingEvent.php)** - disptached at the very end of the process, this allows you to alter the DTO before it's passed to the controller. +class CreatePostController extends AbstractController +{ + #[Dto(extractor: JsonExtractor::class, subject: PostPayload::class, validate: true)] + public function __invoke(PostPayload $postPayload): Response + { + // At this stage, your DTO has automatically been mapped (from the JSON input) and validated. + // Your controller can safely be executed knowing that the submitted content + // matches your requirements (defined in your DTO through the validator constraints). + } +} +``` + +**Alternatively**, you can set the attribute directly on the argument: +```php +public function __invoke(#[Dto(extractor: JsonExtractor::class, validate: true)] PostPayload $postPayload): Response +{ +} +``` + +If you have set some default options in the configuration file (the default extractor to use, whether to enable the validation), you can even make it shorter: +```php +public function __invoke(#[Dto] PostPayload $postPayload): Response +{ +} +``` + +3. That's it! + +## Attribute +The [Dto attribute](src/Attribute/Dto.php) has the following seven properties: + +### 1. Extractor +The FQCN (Fully-Qualified Class Name) of a class that implements the `ExtractorInterface`. It basically contains the extraction logic and it's called by the mapper in order to extract the data from the request. + +The bundle already comes with 3 built-in extractors that should meet most of your use-cases: +- [BodyParameterExtractor](/src/Extractor/BodyParameterExtractor.php) (extracts the data from `$request->request->all()`) +- [JsonExtractor](/src/Extractor/JsonExtractor.php) (extracts the data from `$request->toArray()`) +- [QueryStringExtractor](/src/Extractor/QueryStringExtractor.php) (extracts the data from `$request->query->all()`) + +If an error occurs when the `extract()` method is called from the extractor class, the [ExtractionFailedException](src/Exception/ExtractionFailedException.php) will be thrown. + +If these built-in extractor classes don't meet your needs, you can implement your own extractor like this: + +```php +use Artyum\RequestDtoMapperBundle\Extractor\ExtractorInterface; +use Symfony\Component\HttpFoundation\Request; + +class CustomExtractor implements ExtractorInterface +{ + // you can optionally inject dependencies + public function __construct() { + } + + public function extract(Request $request): array + { + // your custom extraction logic here + } +} +``` +Then pass it to the `Dto` attribute like this: + +```php +#[Dto(extractor: CustomExtractor::class)] +``` + +If you don't set any value, the default value (defined in the bundle's configuration file) will be used. + +**Note:** All classes implementing `ExtractorInterface` are automatically tagged as "artyum_request_dto_mapper.extractor", +and this is needed by the mapper in order to retrieve the needed extractor class instance from the container. -## Known limitations -1. Doesn't work with DTOs that have dependencies. (e.g. injecting a class in the DTO's constructor) +### 2. Subject +The FQCN (Fully-Qualified Class Name) of the DTO you want to map (it must be present as your controller argument). + +The "subject" property is required **only** if you're setting the attribute directly on the method. Example: + +```php +#[Dto(subject: PostPayload::class)] +public function __invoke(PostPayload $postPayload): Response +{ +} +``` + +If you're setting the attribute on the method argument instead, the "subject" value can be omitted and won't be read by the mapper. Example: +```php +public function __invoke(#[Dto] PostPayload $postPayload): Response +{ +} +``` + +### 3. Methods +It can contain a single or an array of HTTP methods that will "enable" the mapping/validation depending on the current HTTP method. In the following example, the DTO will be mapped & validated only if the request method is "GET". +```php +#[Dto(methods: 'GET')] +``` +or +```php +#[Dto(methods: ['GET'])] +``` + +If the array is empty (this is the default value), the mapper will always map the DTO and validate it. + +### 4. Denormalization Options +The options that will be passed to the [Denormalizer](https://symfony.com/doc/current/components/serializer.html) before mapping the DTO. + +Example: +```php +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +#[Dto(denormalizerOptions: [ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true])] +``` + +If an error occurs when the `denormalize()` method is called from the Denormalizer, the [DtoMappingFailedException](src/Exception/DtoMappingFailedException.php) will be thrown. + +### 5. Validate +Whether to validate the DTO (once the mapping is done). Internally, the [validator component](https://symfony.com/doc/current/validation.html) will be used, and if you do not have it installed a `LogicException` will be thrown. + +Example: +```php +#[Dto(validate: true)] +``` + +If the validation failed (due to the constraint violations), the constraint violations will be available as request attribute: +```php +$request->attributes->get('_constraint_violations') +``` + +If you don't set any value, the configured value (defined in the bundle's configuration file) will be used. + +### 6. Validation Groups +The [validation groups](https://symfony.com/doc/current/form/validation_groups.html) to pass to the validator. + +Example: +```php +#[Dto(validationGroups: ['creation'])] +``` + +If you don't set any value, the configured value (defined in the bundle's configuration file) will be used. + +### 7. Throw on violation +When the validation failed, the [DtoValidationFailedException](/src/Exception/DtoValidationFailedException.php) will be thrown, and you will be able to get a list of these violations by calling the `getViolations()` method. + +Setting the value to `false` will prevent the exception from being thrown, and your controller will still be executed. + +Example: +```php +#[Dto(throwOnViolation: false)] +``` + +If you don't set any value, the configured value (defined in the bundle's configuration file) will be used. + +## Events +- [PreDtoMappingEvent](/src/Event/PreDtoMappingEvent.php) - dispatched before the mapping is made. +- [PostDtoMappingEvent](/src/Event/PostDtoMappingEvent.php) - dispatched once the mapping is made. +- [PreDtoValidationEvent](/src/Event/PreDtoValidationEvent.php) - dispatched before the validation is made (if the validation is enabled). +- [PostDtoValidationEvent](/src/Event/PostDtoValidationEvent.php) - dispatched once the validation is made (if the validation is enabled). diff --git a/composer.json b/composer.json index ee0b6e3..be1ec6d 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,33 @@ { "name": "artyuum/request-dto-mapper-bundle", "description": "This bundle provides an easy way to automatically map the incoming request data to a DTO and optionally validate it.", + "keywords": [ + "symfony", + "bundle", + "dto", + "api", + "request-mapper", + "mapper" + ], "type": "symfony-bundle", "require": { - "php": "^7.4 || ^8.0", - "symfony/validator": "^5.0 || ^6.0", - "symfony/serializer": "^5.0 || ^6.0", + "php": "^8.0", + "phpdocumentor/reflection-docblock": "^5.3", + "symfony/config": "^5.0 || ^6.0", + "symfony/event-dispatcher": "^5.0 || ^6.0", + "symfony/framework-bundle": "^5.0 || ^6.0", "symfony/http-kernel": "^5.0 || ^6.0", - "doctrine/annotations": "^1.8", - "symfony/config": "^5.0 || ^6.0" + "symfony/property-access": "^5.0 || ^6.0", + "symfony/property-info": "^5.0 || ^6.0", + "symfony/serializer": "^5.0 || ^6.0" + }, + "suggest": { + "symfony/validator": "For validating the DTO (if the validation is enabled)." }, "license": "MIT", "authors": [ { - "name": "Artyum Petrov", + "name": "Dynèsh HASSANALY", "email": "artyum@protonmail.com" } ], @@ -24,5 +38,30 @@ "exclude-from-classmap": [ "/tests/" ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.8", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-symfony": "^1.2", + "phpunit/phpunit": "^9.5", + "symfony/validator": "^6.0" + }, + "scripts": { + "php-cs-fixer": "./vendor/bin/php-cs-fixer fix", + "phpstan": "./vendor/bin/phpstan analyze" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3058e27 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 9 + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + reportUnmatchedIgnoredErrors: false + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..cb23be1 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Annotation/Dto.php b/src/Annotation/Dto.php deleted file mode 100644 index 93b39a1..0000000 --- a/src/Annotation/Dto.php +++ /dev/null @@ -1,21 +0,0 @@ -dtoMapper = $dtoMapper; - } - - public function supports(Request $request, ArgumentMetadata $argument): bool - { - return - class_exists($argument->getType()) && - in_array(DtoInterface::class, class_implements($argument->getType()), true) - ; - } - - public function resolve(Request $request, ArgumentMetadata $argument): iterable - { - yield $this->dtoMapper->map($request, $argument->getType()); - } -} diff --git a/src/Attribute/Dto.php b/src/Attribute/Dto.php new file mode 100644 index 0000000..39082a6 --- /dev/null +++ b/src/Attribute/Dto.php @@ -0,0 +1,103 @@ +methods = is_array($methods) ? $methods : [$methods]; + } + + public function getExtractor(): ?string + { + return $this->extractor; + } + + public function setExtractor(?string $extractor): self + { + $this->extractor = $extractor; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getMethods(): array + { + return $this->methods; + } + + public function setMethods(array|string $methods): self + { + $this->methods = is_array($methods) ? $methods : [$methods]; + + return $this; + } + + public function getDenormalizerOptions(): array + { + return $this->denormalizerOptions; + } + + public function setDenormalizerOptions(array $denormalizerOptions): self + { + $this->denormalizerOptions = $denormalizerOptions; + + return $this; + } + + public function getValidate(): ?bool + { + return $this->validate; + } + + public function setValidate(?bool $validate): self + { + $this->validate = $validate; + + return $this; + } + + public function getValidationGroups(): array + { + return $this->validationGroups; + } + + public function setValidationGroups(array $validationGroups): self + { + $this->validationGroups = $validationGroups; + + return $this; + } + + public function getThrowOnViolation(): ?bool + { + return $this->throwOnViolation; + } + + public function setThrowOnViolation(?bool $throwOnViolation): self + { + $this->throwOnViolation = $throwOnViolation; + + return $this; + } +} diff --git a/src/DependencyInjection/ArtyumRequestDtoMapperExtension.php b/src/DependencyInjection/ArtyumRequestDtoMapperExtension.php index a8e2f29..ddcf841 100644 --- a/src/DependencyInjection/ArtyumRequestDtoMapperExtension.php +++ b/src/DependencyInjection/ArtyumRequestDtoMapperExtension.php @@ -2,21 +2,32 @@ namespace Artyum\RequestDtoMapperBundle\DependencyInjection; -use Symfony\Component\DependencyInjection\ContainerBuilder; +use Artyum\RequestDtoMapperBundle\Mapper\Mapper; +use Artyum\RequestDtoMapperBundle\Extractor\ExtractorInterface; use Symfony\Component\Config\FileLocator; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -class ArtyumRequestDtoMapperExtension extends Extension +class ArtyumRequestDtoMapperExtension extends ConfigurableExtension { - public function load(array $configs, ContainerBuilder $container): void + /** + * {@inheritdoc} + */ + protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void { - $configuration = new Configuration(); - $config = $this->processConfiguration($configuration, $configs); + $loader = new PhpFileLoader($container, new FileLocator(dirname(__DIR__) . '/Resources/config')); + $loader->load('services.php'); - $loader = new Loader\XmlFileLoader($container, new FileLocator(dirname(__DIR__) . '/Resources/config')); - $loader->load('services.xml'); + $container + ->registerForAutoconfiguration(ExtractorInterface::class) + ->addTag('artyum_request_dto_mapper.extractor') + ; - $container->setParameter('artyum_request_dto_mapper.enabled', $config['enabled']); + $container->getDefinition(Mapper::class) + ->replaceArgument(0, $mergedConfig['denormalizer']) + ->replaceArgument(1, $mergedConfig['validation']) + ->replaceArgument(7, $mergedConfig['default_extractor']) + ; } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 246204d..1a37e2a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -8,6 +8,9 @@ class Configuration implements ConfigurationInterface { + /** + * {@inheritdoc} + */ public function getConfigTreeBuilder(): NodeParentInterface { $treeBuilder = new TreeBuilder('artyum_request_dto_mapper'); @@ -15,8 +18,46 @@ public function getConfigTreeBuilder(): NodeParentInterface $rootNode ->children() - ->booleanNode('enabled') - ->defaultTrue() + ->scalarNode('default_extractor') + ->info('Used if the attribute does not specify any (must be a FQCN implementing "\Artyum\RequestDtoMapperBundle\Extractor\ExtractorInterface").') + ->example('Artyum\RequestDtoMapperBundle\Extractor\JsonExtractor') + ->defaultNull() + ->end() + ->arrayNode('denormalizer') + ->info('The configuration related to the denormalizer (https://symfony.com/doc/current/components/serializer.html).') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('default_options') + ->info('Used when mapping the request data to the DTO if the attribute does not set any.') + ->prototype('scalar')->end() + ->end() + ->arrayNode('additional_options') + ->info('Used when mapping the request data to the DTO (merged with the values passed by the attribute or "default_options").') + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->arrayNode('validation') + ->info('The configuration related to the validator (https://symfony.com/doc/current/validation.html).') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled') + ->info('Whether to validate the DTO after mapping it.') + ->defaultFalse() + ->end() + ->arrayNode('default_groups') + ->info('Used when validating the DTO if the attribute does not set any.') + ->prototype('scalar')->end() + ->end() + ->arrayNode('additional_groups') + ->info('Used when validating the DTO (merged with the values passed by the attribute or "default_groups").') + ->prototype('scalar')->end() + ->end() + ->booleanNode('throw_on_violation') + ->info('Whether to throw an exception if the DTO validation failed (constraint violations).') + ->defaultTrue() + ->end() + ->end() ->end() ; diff --git a/src/Event/PostDtoMappingEvent.php b/src/Event/PostDtoMappingEvent.php index 3cbe74e..d38de3a 100644 --- a/src/Event/PostDtoMappingEvent.php +++ b/src/Event/PostDtoMappingEvent.php @@ -2,32 +2,36 @@ namespace Artyum\RequestDtoMapperBundle\Event; -use Artyum\RequestDtoMapperBundle\Annotation\Dto; -use Artyum\RequestDtoMapperBundle\Mapper\DtoInterface; +use Artyum\RequestDtoMapperBundle\Attribute\Dto; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\EventDispatcher\Event; /** - * This event is dispatched at the very end of the mapping (and after the validation if enabled), this allows you to alter the DTO before it's passed to the controller. + * This event is dispatched once the mapping is done. If the validation is disabled, this would be the last event that is dispatched before your controller is called. */ class PostDtoMappingEvent extends Event { - private DtoInterface $dto; + public function __construct(private Request $request, private Dto $attribute, private object $subject, private array $data) + { + } - public function __construct(DtoInterface $dto) + public function getRequest(): Request { - $this->dto = $dto; + return $this->request; } - public function getDto(): DtoInterface + public function getAttribute(): Dto { - return $this->dto; + return $this->attribute; } - public function setDto(DtoInterface $dto): self + public function getSubject(): object { - $this->dto = $dto; + return $this->subject; + } - return $this; + public function getData(): array + { + return $this->data; } } diff --git a/src/Event/PostDtoValidationEvent.php b/src/Event/PostDtoValidationEvent.php new file mode 100644 index 0000000..a55028f --- /dev/null +++ b/src/Event/PostDtoValidationEvent.php @@ -0,0 +1,40 @@ +request; + } + + public function getAttribute(): Dto + { + return $this->attribute; + } + + public function getSubject(): object + { + return $this->subject; + } + + public function getErrors(): ConstraintViolationListInterface + { + return $this->errors; + } +} diff --git a/src/Event/PreDtoMappingEvent.php b/src/Event/PreDtoMappingEvent.php index 0b2bb7b..238d110 100644 --- a/src/Event/PreDtoMappingEvent.php +++ b/src/Event/PreDtoMappingEvent.php @@ -2,29 +2,18 @@ namespace Artyum\RequestDtoMapperBundle\Event; -use Artyum\RequestDtoMapperBundle\Annotation\Dto; +use Artyum\RequestDtoMapperBundle\Attribute\Dto; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\EventDispatcher\Event; /** - * This event is dispatched before the mapping is made, this allows you to alter the Serializer/Denormalizer options or the Request object. + * This event is dispatched before the mapping is made, this allows you to alter all the passed objects, + * including the extracted data before it's mapped to the DTO. */ class PreDtoMappingEvent extends Event { - private Request $request; - - private string $dto; - - private Dto $dtoAnnotation; - - private array $options; - - public function __construct(Request $request, string $dto, Dto $dtoAnnotation, array $options = []) + public function __construct(private Request $request, private Dto $attribute, private object $subject, private array $data) { - $this->dto = $dto; - $this->dtoAnnotation = $dtoAnnotation; - $this->request = $request; - $this->options = $options; } public function getRequest(): Request @@ -32,46 +21,23 @@ public function getRequest(): Request return $this->request; } - public function setRequest(Request $request): self + public function getAttribute(): Dto { - $this->request = $request; - - return $this; + return $this->attribute; } - public function getDto(): string + public function getSubject(): object { - return $this->dto; - } - - public function setDto(string $dto): self - { - $this->dto = $dto; - - return $this; + return $this->subject; } - public function getDtoAnnotation(): Dto + public function getData(): array { - return $this->dtoAnnotation; + return $this->data; } - public function setDtoAnnotation(Dto $dtoAnnotation): self + public function setData(array $data): void { - $this->dtoAnnotation = $dtoAnnotation; - - return $this; - } - - public function getOptions(): array - { - return $this->options; - } - - public function setOptions(array $options): self - { - $this->options = $options; - - return $this; + $this->data = $data; } } diff --git a/src/Event/PreDtoValidationEvent.php b/src/Event/PreDtoValidationEvent.php index cfc00da..5b0d951 100644 --- a/src/Event/PreDtoValidationEvent.php +++ b/src/Event/PreDtoValidationEvent.php @@ -2,27 +2,17 @@ namespace Artyum\RequestDtoMapperBundle\Event; -use Artyum\RequestDtoMapperBundle\Annotation\Dto; -use Artyum\RequestDtoMapperBundle\Mapper\DtoInterface; +use Artyum\RequestDtoMapperBundle\Attribute\Dto; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\EventDispatcher\Event; /** - * This event is dispatched before the validation is made, this allows you to alter the DTO object. + * This event is dispatched before the validation is made, this allows you to alter the DTO before it's being passed to the validator. */ class PreDtoValidationEvent extends Event { - private Request $request; - - private DtoInterface $dto; - - private Dto $dtoAnnotation; - - public function __construct(Request $request, DtoInterface $dto, Dto $dtoAnnotation) + public function __construct(private Request $request, private Dto $attribute, private object $subject) { - $this->dto = $dto; - $this->dtoAnnotation = $dtoAnnotation; - $this->request = $request; } public function getRequest(): Request @@ -30,37 +20,13 @@ public function getRequest(): Request return $this->request; } - /** - * @return PreDtoValidationEvent - */ - public function setRequest(Request $request): self - { - $this->request = $request; - - return $this; - } - - public function getDto(): DtoInterface + public function getAttribute(): Dto { - return $this->dto; + return $this->attribute; } - public function setDto(DtoInterface $dto): self + public function getSubject(): object { - $this->dto = $dto; - - return $this; - } - - public function getDtoAnnotation(): Dto - { - return $this->dtoAnnotation; - } - - public function setDtoAnnotation(Dto $dtoAnnotation): self - { - $this->dtoAnnotation = $dtoAnnotation; - - return $this; + return $this->subject; } } diff --git a/src/EventListener/ControllerArgumentsEventListener.php b/src/EventListener/ControllerArgumentsEventListener.php new file mode 100644 index 0000000..a534799 --- /dev/null +++ b/src/EventListener/ControllerArgumentsEventListener.php @@ -0,0 +1,179 @@ +> + */ + private function extractFromMethod(ReflectionMethod $reflectionMethod): array + { + $subjects = []; + $alreadyExtractedSubjects = []; + $reflectionAttributes = $reflectionMethod->getAttributes(Dto::class); + + foreach ($reflectionAttributes as $reflectionAttribute) { + /** @var Dto $dtoAttribute */ + $dtoAttribute = $reflectionAttribute->newInstance(); + + if (!$dtoAttribute->getSubject()) { + throw new LogicException(sprintf('When used as a method attribute, you must set the $subject argument on the %s attribute.', Dto::class)); + } + + if (in_array($dtoAttribute->getSubject(), $alreadyExtractedSubjects, true)) { + throw new LogicException(sprintf('The subject %s is present more than once in the method arguments. In that case, you must configure the attribute directly on the argument itself and not on the method.', $dtoAttribute->getSubject())); + } + + $alreadyExtractedSubjects[] = $dtoAttribute->getSubject(); + $subjects[$dtoAttribute->getSubject()][] = $reflectionAttribute->newInstance(); + } + + return $subjects; + } + + /** + * Extracts the Dto subjects from the passed ReflectionMethod parameters. + */ + private function extractFromParameters(ReflectionMethod $reflectionMethod): array + { + $subjects = []; + + foreach ($reflectionMethod->getParameters() as $index => $reflectionParameter) { + $reflectionAttributes = $reflectionParameter->getAttributes(Dto::class); + + if (!$reflectionAttributes) { + continue; + } + + $subjects[$index] = [ + /* @phpstan-ignore-next-line */ + 'argument' => $reflectionParameter->getType()->getName(), + ]; + + foreach ($reflectionAttributes as $reflectionAttribute) { + /** @var Dto $dtoAttribute */ + $dtoAttribute = $reflectionAttribute->newInstance(); + + $subjects[$index]['attributes'][] = $dtoAttribute; + } + } + + return $subjects; + } + + /** + * @throws ReflectionException + * @throws DtoMappingFailedException + * @throws DtoValidationFailedException + * @throws ExtractionFailedException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + $controller = $event->getController(); + $request = $event->getRequest(); + $reflectionMethod = $this->getReflectionMethod($controller); + + $dtoAttributesFromMethod = $this->extractFromMethod($reflectionMethod); + $dtoAttributesFromParameters = $this->extractFromParameters($reflectionMethod); + + if (!$dtoAttributesFromMethod && !$dtoAttributesFromParameters) { + return; + } + + foreach ($dtoAttributesFromMethod as $subjectFqcn => $dtoAttributes) { + foreach ($dtoAttributes as $dtoAttribute) { + if ($dtoAttribute->getMethods() && !in_array($request->getMethod(), $dtoAttribute->getMethods(), true)) { + continue; + } + + $subjectInstance = $this->getSubjectInstanceFromControllerArguments($subjectFqcn, $event->getArguments()); + + if (!$subjectInstance) { + throw new LogicException(sprintf('The subject (%s) was not found in the controller arguments.', $subjectFqcn)); + } + + $this->mapper->map($dtoAttribute, $subjectInstance); + $this->mapper->validate($dtoAttribute, $subjectInstance); + } + } + + foreach ($dtoAttributesFromParameters as $index => $dtoAttributesFromParameter) { + $subjectInstance = $event->getArguments()[$index]; + + foreach ($dtoAttributesFromParameter['attributes'] as $dtoAttribute) { + if ($dtoAttribute->getMethods() && !in_array($request->getMethod(), $dtoAttribute->getMethods(), true)) { + continue; + } + + $this->mapper->map($dtoAttribute, $subjectInstance); + $this->mapper->validate($dtoAttribute, $subjectInstance); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments', + ]; + } +} diff --git a/src/Exception/DtoDefinitionException.php b/src/Exception/DtoDefinitionException.php deleted file mode 100644 index 6fbd176..0000000 --- a/src/Exception/DtoDefinitionException.php +++ /dev/null @@ -1,12 +0,0 @@ -violations = $violations; + parent::__construct($message, $code, $previous); } public function getViolations(): ConstraintViolationListInterface diff --git a/src/Exception/ExtractionFailedException.php b/src/Exception/ExtractionFailedException.php new file mode 100644 index 0000000..0c38bc9 --- /dev/null +++ b/src/Exception/ExtractionFailedException.php @@ -0,0 +1,17 @@ +request->all(); + } +} diff --git a/src/Extractor/ExtractorInterface.php b/src/Extractor/ExtractorInterface.php new file mode 100644 index 0000000..5e19f6f --- /dev/null +++ b/src/Extractor/ExtractorInterface.php @@ -0,0 +1,13 @@ +toArray(); + } +} diff --git a/src/Extractor/QueryStringExtractor.php b/src/Extractor/QueryStringExtractor.php new file mode 100644 index 0000000..3df877d --- /dev/null +++ b/src/Extractor/QueryStringExtractor.php @@ -0,0 +1,16 @@ +query->all(); + } +} diff --git a/src/Helper/ConstraintViolationListHelper.php b/src/Helper/ConstraintViolationListHelper.php deleted file mode 100644 index 31e379a..0000000 --- a/src/Helper/ConstraintViolationListHelper.php +++ /dev/null @@ -1,26 +0,0 @@ - value array. - */ -class ConstraintViolationListHelper -{ - /** - * Gets the validator errors. - */ - public static function toArray(ConstraintViolationListInterface $violations): ?array - { - $errors = null; - - // loops through all errors and stores only the needed informations to be displayed - foreach ($violations as $violation) { - $errors[$violation->getPropertyPath()][] = $violation->getMessage(); - } - - return $errors; - } -} diff --git a/src/Mapper/DtoInterface.php b/src/Mapper/DtoInterface.php deleted file mode 100644 index 02eab26..0000000 --- a/src/Mapper/DtoInterface.php +++ /dev/null @@ -1,10 +0,0 @@ - true, - ]; - - public function __construct( - EventDispatcherInterface $eventDispatcher, SerializerInterface $serializer, DenormalizerInterface $denormalizer, - ValidatorInterface $validator - ) { - $this->serializer = $serializer; - $this->denormalizer = $denormalizer; - $this->validator = $validator; - $this->eventDispatcher = $eventDispatcher; - } - - /** - * Maps the passed array to the DTO. - * - * @throws ExceptionInterface - */ - private function mapFromArray(array $array, string $dto, array $options = []): DtoInterface - { - $options = array_merge($this->defaultMapperOptions, $options); - - /** @var DtoInterface $dto */ - $dto = $this->denormalizer->denormalize($array, $dto, null, $options); - - return $dto; - } - - /** - * Maps the passed raw body (assuming its JSON) to the DTO. - * - * @throws DtoMappingException - */ - private function mapFromJson(string $rawBody, string $dto, array $options = []): DtoInterface - { - $options = array_merge($this->defaultMapperOptions, $options); - - try { - /** @var DtoInterface $dto */ - $dto = $this->serializer->deserialize($rawBody, $dto, 'json', $options); - } catch (Throwable $throwable) { - throw new DtoMappingException($throwable->getMessage(), 0, $throwable); - } - - return $dto; - } - - /** - * Gets the right Dto annotation class instance from the passed FQCN for the current HTTP method. - * - * @throws ReflectionException - */ - private function getDtoAnnotation(string $dto, string $method): ?Dto - { - $reflectionClass = new ReflectionClass($dto); - $annotationReader = new AnnotationReader(); - $annotations = $annotationReader->getClassAnnotations($reflectionClass); - - // loops through all annotations of the class and return the Dto annotation class instance if found - foreach ($annotations as $annotation) { - if (!$annotation instanceof Dto) { - continue; - } - - if (in_array($method, $annotation->methods, true)) { - return $annotation; - } - } - - // gets the Dto annotation class instance from the parent class if it wasn't found above - if ($reflectionClass->getParentClass() && $reflectionClass->getParentClass()->implementsInterface(DtoInterface::class)) { - return $this->getDtoAnnotation($reflectionClass->getParentClass()->getName(), $method); - } - - return null; - } - - /** - * @return array|bool[] - */ - public function getDefaultMapperOptions() - { - return $this->defaultMapperOptions; - } - - /** - * Maps the request data to the DTO. - * - * @throws DtoDefinitionException - * @throws DtoMappingException - * @throws DtoValidationException - * @throws ExceptionInterface - * @throws ReflectionException - */ - public function map(Request $request, string $dto): DtoInterface - { - $dtoAnnotation = $this->getDtoAnnotation($dto, $request->getMethod()); - - if (!$dtoAnnotation) { - throw new DtoDefinitionException('There is no context set for the current HTTP method: ' . $request->getMethod()); - } - - /** @var PreDtoMappingEvent $preDtoMappingEvent */ - $preDtoMappingEvent = $this->eventDispatcher->dispatch(new PreDtoMappingEvent($request, $dto, $dtoAnnotation)); - - // calls the proper "mapper" method based on the passed source - if ($dtoAnnotation->source === 'query_strings') { - $dto = $this->mapFromArray($preDtoMappingEvent->getRequest()->query->all(), $dto, $preDtoMappingEvent->getOptions()); - } elseif ($dtoAnnotation->source === 'body_parameters') { - $dto = $this->mapFromArray($preDtoMappingEvent->getRequest()->request->all(), $dto, $preDtoMappingEvent->getOptions()); - } elseif ($dtoAnnotation->source === 'files') { - $dto = $this->mapFromArray($preDtoMappingEvent->getRequest()->files->all(), $dto, $preDtoMappingEvent->getOptions()); - } elseif ($dtoAnnotation->source === 'json') { - $dto = $this->mapFromJson($preDtoMappingEvent->getRequest()->getContent(), $dto, $preDtoMappingEvent->getOptions()); - } - - if ($dtoAnnotation->validation) { - /** @var PreDtoValidationEvent $preDtoValidationEvent */ - $preDtoValidationEvent = $this->eventDispatcher->dispatch(new PreDtoValidationEvent($request, $dto, $dtoAnnotation)); - - if (count($errors = $this->validator->validate($preDtoValidationEvent->getDto(), null, $dtoAnnotation->validationGroups))) { - throw new DtoValidationException($errors); - } - } - - /** @var PostDtoMappingEvent $postDtoMappingEvent */ - $postDtoMappingEvent = $this->eventDispatcher->dispatch(new PostDtoMappingEvent($dto)); - - return $postDtoMappingEvent->getDto(); - } -} diff --git a/src/Mapper/Mapper.php b/src/Mapper/Mapper.php new file mode 100644 index 0000000..0e05a1f --- /dev/null +++ b/src/Mapper/Mapper.php @@ -0,0 +1,164 @@ +denormalizerConfiguration['default_options']; + } + + return array_merge_recursive($options, $this->denormalizerConfiguration['additional_options']); + } + + /** + * Gets the groups to the pass to the validator. + */ + private function getValidationGroups(array $attributeValidationGroups = []): array + { + if ($attributeValidationGroups) { + $groups = $attributeValidationGroups; + } else { + $groups = $this->validationConfiguration['default_groups']; + } + + return array_merge_recursive($groups, $this->validationConfiguration['additional_groups']); + } + + /** + * Whether to validate the DTO. + */ + private function canValidate(Dto $attribute): bool + { + if (is_bool($attribute->getValidate())) { + return $attribute->getValidate(); + } + + return $this->validationConfiguration['enabled']; + } + + /** + * Validates the subject (already mapped DTO). + * + * @throws DtoValidationFailedException + */ + public function validate(Dto $attribute, object $subject): void + { + if (!$this->canValidate($attribute)) { + return; + } + + if (!$this->validator) { + throw new LogicException('You cannot validate the DTO if the "validator" component is not available. Try running "composer require symfony/validator".'); + } + + /** @var Request $request */ + $request = $this->requestStack->getMainRequest(); + + $this->eventDispatcher->dispatch(new PreDtoValidationEvent($request, $attribute, $subject)); + + $validationGroups = $this->getValidationGroups($attribute->getValidationGroups()); + + $errors = $this->validator->validate($subject, null, $validationGroups); + + if ($errors->count()) { + $request->attributes->set('_constraint_violations', $errors); + + $canThrowOnViolation = $attribute->getThrowOnViolation(); + + if (!is_bool($canThrowOnViolation)) { + $canThrowOnViolation = $this->validationConfiguration['throw_on_violation']; + } + + if ($canThrowOnViolation) { + throw new DtoValidationFailedException($errors); + } + } + + $this->eventDispatcher->dispatch(new PostDtoValidationEvent($request, $attribute, $subject, $errors)); + } + + /** + * Maps the request data to the DTO. + * + * @throws DtoMappingFailedException + * @throws ExtractionFailedException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function map(Dto $attribute, object $subject): void + { + /** @var Request $request */ + $request = $this->requestStack->getMainRequest(); + + $extractor = $attribute->getExtractor() ?? $this->defaultExtractorConfiguration; + + if (!$extractor) { + throw new LogicException('You must set an extractor either on the attribute or in the configuration file.'); + } + + if (!$this->extractorLocator->has($extractor)) { + throw new LogicException(sprintf('Unable to the find the passed extractor "%s" in the container. Make sure it\'s tagged as "artyum_request_dto_mapper.extractor".', $extractor)); + } + + /** @var ExtractorInterface $extractorInstance */ + $extractorInstance = $this->extractorLocator->get($extractor); + + try { + $data = $extractorInstance->extract($request); + } catch (Throwable $throwable) { + throw new ExtractionFailedException(previous: $throwable); + } + + $preDtoMappingEvent = new PreDtoMappingEvent($request, $attribute, $subject, $data); + + $this->eventDispatcher->dispatch($preDtoMappingEvent); + + $denormalizerOptions = $this->getDenormalizerOptions($attribute->getDenormalizerOptions()); + $denormalizerOptions[AbstractNormalizer::OBJECT_TO_POPULATE] = $subject; + + try { + $this->denormalizer->denormalize($preDtoMappingEvent->getData(), $subject::class, null, $denormalizerOptions); + } catch (Throwable $throwable) { + throw new DtoMappingFailedException(previous: $throwable); + } + + $this->eventDispatcher->dispatch(new PostDtoMappingEvent($request, $attribute, $subject, $data)); + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php new file mode 100644 index 0000000..58c7340 --- /dev/null +++ b/src/Resources/config/services.php @@ -0,0 +1,52 @@ +services() + // Listeners + ->set(ControllerArgumentsEventListener::class) + ->args([ + service(Mapper::class), + ]) + ->tag('kernel.event_listener', ['event' => KernelEvents::CONTROLLER_ARGUMENTS, 'priority' => -1]) + + // Services + ->set(Mapper::class) + ->args([ + param('denormalizer'), + param('validation'), + tagged_locator('artyum_request_dto_mapper.extractor'), + service(RequestStack::class), + service(EventDispatcherInterface::class), + service(DenormalizerInterface::class), + service(ValidatorInterface::class)->nullOnInvalid(), + param('default_extractor'), + ]) + + // Extractors + ->set(BodyParameterExtractor::class) + ->tag('artyum_request_dto_mapper.extractor') + ->set(JsonExtractor::class) + ->tag('artyum_request_dto_mapper.extractor') + ->set(QueryStringExtractor::class) + ->tag('artyum_request_dto_mapper.extractor') + ; +}; diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml deleted file mode 100644 index ecae5c0..0000000 --- a/src/Resources/config/services.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/tests/EventListener/ControllerArgumentsEventListenerTest.php b/tests/EventListener/ControllerArgumentsEventListenerTest.php new file mode 100644 index 0000000..77b0604 --- /dev/null +++ b/tests/EventListener/ControllerArgumentsEventListenerTest.php @@ -0,0 +1,83 @@ +createMock(HttpKernelInterface::class), + [new Controller(), 'controllerNotUsingTheAttribute'], + [], + new Request(), + null + ); + + $mapperMock = $this->createMock(Mapper::class); + + $mapperMock->expects(self::never())->method('map'); + $mapperMock->expects(self::never())->method('validate'); + + $listener = new ControllerArgumentsEventListener($mapperMock); + + $listener->onKernelControllerArguments($event); + } + + public function testItThrowsAnExceptionWhenTheAttributeIsUsedWithoutAKnownSubject(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('When used as a method attribute, you must set the $subject argument on the %s attribute.',Dto::class)); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new Controller(), 'attributeDoesNotHaveAKnownSubject'], + [], + new Request(), + null + ); + + $mapperMock = $this->createMock(Mapper::class); + + $listener = new ControllerArgumentsEventListener($mapperMock); + + $listener->onKernelControllerArguments($event); + } + + public function testItThrowsAnExceptionWhenTheSubjectIsNotFound(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The subject (%s) was not found in the controller arguments.', stdClass::class)); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new Controller(), 'subjectIsNotFound'], + [], + new Request(), + null, + ); + + $mapperMock = $this->createMock(Mapper::class); + + $listener = new ControllerArgumentsEventListener($mapperMock); + + $listener->onKernelControllerArguments($event); + } +} diff --git a/tests/Extractor/BodyParameterExtractorTest.php b/tests/Extractor/BodyParameterExtractorTest.php new file mode 100644 index 0000000..adee002 --- /dev/null +++ b/tests/Extractor/BodyParameterExtractorTest.php @@ -0,0 +1,29 @@ + 'value', + ]; + $request = new Request(request: $expectedData); + $extractor = new BodyParameterExtractor(); + + $extractedData = $extractor->extract($request); + + self::assertSame($expectedData, $extractedData); + } +} diff --git a/tests/Extractor/Fixture/sample.txt b/tests/Extractor/Fixture/sample.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/Extractor/JsonExtractorTest.php b/tests/Extractor/JsonExtractorTest.php new file mode 100644 index 0000000..08c93b4 --- /dev/null +++ b/tests/Extractor/JsonExtractorTest.php @@ -0,0 +1,31 @@ + 'value', + ]; + /** @var string $content */ + $content = json_encode($expectedData); + $request = new Request(content: $content); + $extractor = new JsonExtractor(); + + $extractedData = $extractor->extract($request); + + self::assertSame($expectedData, $extractedData); + } +} diff --git a/tests/Extractor/QueryStringExtractorTest.php b/tests/Extractor/QueryStringExtractorTest.php new file mode 100644 index 0000000..4522412 --- /dev/null +++ b/tests/Extractor/QueryStringExtractorTest.php @@ -0,0 +1,29 @@ + 'value', + ]; + $request = new Request(query: $expectedData); + $extractor = new QueryStringExtractor(); + + $extractedData = $extractor->extract($request); + + self::assertSame($expectedData, $extractedData); + } +} diff --git a/tests/Fixtures/Controller/Controller.php b/tests/Fixtures/Controller/Controller.php new file mode 100644 index 0000000..5d0fdef --- /dev/null +++ b/tests/Fixtures/Controller/Controller.php @@ -0,0 +1,17 @@ + [], + 'additional_options' => [], + ]; + + $validationConfiguration = [ + 'enabled' => false, + 'default_groups' => [], + 'additional_groups' => [], + 'throw_on_violation' => true, + ]; + + $serializer = new Serializer([ + new ObjectNormalizer(propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [ + new PhpDocExtractor(), + ])), + ]); + + $serviceLocator = $serviceLocator ?? new ServiceLocator([]); + + $requestStack = new RequestStack(); + $requestStack->push($request ?? new Request()); + + $eventDispatcher = $eventDispatcher ?? new EventDispatcher(); + + return new Mapper( + $denormalizerConfiguration, + $validationConfiguration, + $serviceLocator, + $requestStack, + $eventDispatcher, + $serializer, + $validator, + $defaultExtractor + ); + } + + public function testItThrowsAnExceptionOnMissingExtractor(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('Unable to the find the passed extractor "%s" in the container. Make sure it\'s tagged as "artyum_request_dto_mapper.extractor".', stdClass::class)); + + $this + ->getMapper() + ->map(new Dto(stdClass::class), new stdClass()) + ; + } + + public function testItThrowsAnExceptionOnNonRegisteredExtractor(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('Unable to the find the passed extractor "%s" in the container. Make sure it\'s tagged as "artyum_request_dto_mapper.extractor".', stdClass::class)); + + $this + ->getMapper() + ->map(new Dto(stdClass::class, stdClass::class), new stdClass()) + ; + } + + public function testItThrowsAnExceptionOnUnknownExtractor(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You must set an extractor either on the attribute or in the configuration file.'); + + $this + ->getMapper() + ->map(new Dto(), new stdClass()) + ; + } + + public function testItThrowsAnExceptionOnExtractionFailure(): void + { + $this->expectException(ExtractionFailedException::class); + $this->expectExceptionMessage('Failed to extract the request data.'); + + $serviceLocatorMock = $this->createMock(ServiceLocator::class); + $serviceLocatorMock + ->expects($this->once()) + ->method('has') + ->with(ErroringExtractor::class) + ->willReturn(true) + ; + $serviceLocatorMock + ->expects($this->once()) + ->method('get') + ->with(ErroringExtractor::class) + ->willReturn(new ErroringExtractor()) + ; + + $this + ->getMapper($serviceLocatorMock) + ->map(new Dto(ErroringExtractor::class, stdClass::class), new stdClass()) + ; + } + + public function testItThrowsAnExceptionOnMappingFailure(): void + { + $this->expectException(DtoMappingFailedException::class); + $this->expectExceptionMessage('Failed to map the extracted request data to the DTO.'); + + $serviceLocatorMock = $this->createMock(ServiceLocator::class); + $serviceLocatorMock + ->expects($this->once()) + ->method('has') + ->with(JsonExtractor::class) + ->willReturn(true) + ; + $serviceLocatorMock + ->expects($this->once()) + ->method('get') + ->with(JsonExtractor::class) + ->willReturn(new JsonExtractor()) + ; + + /** @var string $json */ + $json = json_encode([ + 'foo' => 123, + ]); + $request = new Request(content: $json); + + $this + ->getMapper($serviceLocatorMock, $request) + ->map(new Dto(JsonExtractor::class, FooDto::class), new FooDto()) + ; + } + + public function testItMapsWithADefaultConfiguredExtractor(): void + { + $extractorMock = $this + ->getMockBuilder(ExtractorInterface::class) + ->getMock() + ; + + $extractorMock->method('extract')->willReturn(['foo' => 'bar']); + + $serviceLocatorMock = $this->createMock(ServiceLocator::class); + + $serviceLocatorMock + ->expects($this->once()) + ->method('has') + ->with(stdClass::class) + ->willReturn(true) + ; + $serviceLocatorMock + ->expects($this->once()) + ->method('get') + ->with(stdClass::class) + ->willReturn($extractorMock) + ; + + $serviceLocatorMock->method('has')->willReturn(true); + $serviceLocatorMock->method('get')->willReturn($extractorMock); + + $dto = new stdClass(); + $dto->foo = null; + + $this + ->getMapper($serviceLocatorMock, defaultExtractor: ExtractorInterface::class) + ->map(new Dto(stdClass::class), $dto) + ; + + self::assertSame($dto->foo, 'bar'); + } + + public function testItDispatchesTheMappingRelatedEvents(): void + { + $extractorMock = $this + ->getMockBuilder(ExtractorInterface::class) + ->getMock() + ; + + $extractorMock->method('extract')->willReturn([]); + + $serviceLocatorMock = $this->createMock(ServiceLocator::class); + + $serviceLocatorMock + ->expects(self::once()) + ->method('has') + ->with(stdClass::class) + ->willReturn(true) + ; + + $serviceLocatorMock + ->expects(self::once()) + ->method('get') + ->with(stdClass::class) + ->willReturn($extractorMock) + ; + + $eventDispatcherMock = $this->createMock(EventDispatcher::class); + + $eventDispatcherMock + ->expects(self::exactly(2)) + ->method('dispatch') + ->withConsecutive( + [$this->isInstanceOf(PreDtoMappingEvent::class)], + [$this->isInstanceOf(PostDtoMappingEvent::class)] + ) + ; + + $this + ->getMapper($serviceLocatorMock, eventDispatcher: $eventDispatcherMock) + ->map(new Dto(stdClass::class), new stdClass()) + ; + } + + public function testItThrowsAnExceptionIfTheValidatorIsNotInstalled(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You cannot validate the DTO if the "validator" component is not available. Try running "composer require symfony/validator".'); + + $this + ->getMapper() + ->validate(new Dto(validate: true), new stdClass()) + ; + } + + public function testItDoesNotValidateIfDisabled(): void + { + $validatorMock = $this->createMock(ValidatorInterface::class); + + $validatorMock + ->expects($this->never()) + ->method('validate') + ; + + $this + ->getMapper(validator: $validatorMock) + ->validate(new Dto(validate: false), new stdClass()) + ; + } + + public function testItDispatchesTheValidationRelatedEvents(): void + { + $eventDispatcherMock = $this->createMock(EventDispatcher::class); + $eventDispatcherMock->expects(self::exactly(2)) + ->method('dispatch') + ->withConsecutive( + [$this->isInstanceOf(PreDtoValidationEvent::class)], + [$this->isInstanceOf(PostDtoValidationEvent::class)] + ) + ; + + $validatorMock = $this->createMock(ValidatorInterface::class); + + $this + ->getMapper(eventDispatcher: $eventDispatcherMock, validator: $validatorMock) + ->validate(new Dto(validate: true), new stdClass()) + ; + } + + public function testItThrowsAnExceptionOnConstraintViolations(): void + { + $this->expectException(DtoValidationFailedException::class); + $this->expectExceptionMessage('There is one or more constraint violations for the passed DTO.'); + + $validatorMock = $this->createMock(ValidatorInterface::class); + $validatorMock + ->expects($this->once()) + ->method('validate') + ->willReturnCallback(fn () => ConstraintViolationList::createFromMessage('test')) + ; + + $this + ->getMapper(validator: $validatorMock) + ->validate(new Dto(stdClass::class, validate: true), new stdClass()) + ; + } + + public function testItDoesNotThrowAnExceptionOnConstraintViolationsIfConfiguredOnTheAttribute(): void + { + $validatorMock = $this->createMock(ValidatorInterface::class); + $validatorMock + ->expects($this->once()) + ->method('validate') + ->willReturnCallback(fn () => ConstraintViolationList::createFromMessage('test')) + ; + + $this + ->getMapper(validator: $validatorMock) + ->validate(new Dto(stdClass::class, validate: true, throwOnViolation: false), new stdClass()) + ; + } + + public function testItStoresTheConstraintViolationsAsRequestAttribute(): void + { + $constraintViolationList = ConstraintViolationList::createFromMessage('test'); + + $validatorMock = $this->createMock(ValidatorInterface::class); + $validatorMock + ->expects($this->once()) + ->method('validate') + ->willReturnCallback(fn () => $constraintViolationList) + ; + + $request = new Request(); + + $this + ->getMapper(request: $request, validator: $validatorMock) + ->validate(new Dto(stdClass::class, validate: true, throwOnViolation: false), new stdClass()) + ; + + self::assertSame($constraintViolationList, $request->attributes->get('_constraint_violations')); + } +}