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'));
+ }
+}