├── .gitignore ├── DependencyInjection ├── Configuration.php └── RequestDataExtension.php ├── Event └── FinishEvent.php ├── EventListener └── ControllerListener.php ├── Events.php ├── Exception └── NotSupportedFormatException.php ├── Extractor ├── Extractor.php └── ExtractorInterface.php ├── FormatSupportableInterface.php ├── Formats.php ├── LICENSE ├── Mapper ├── Mapper.php └── MapperInterface.php ├── README.md ├── RequestDataBundle.php ├── Resources └── config │ └── services.yml ├── Tests ├── EventListener │ └── ControllerListenerTest.php ├── Extractor │ └── ExtractorTest.php ├── Fixtures │ ├── TestAbstractController.php │ ├── TestController.php │ ├── TestFormatSupportableRequestData.php │ ├── TestRequestData.php │ └── TestRequestDataController.php ├── Mapper │ └── MapperTest.php └── TypeConverter │ └── TypeConverterTest.php ├── TypeConverter ├── TypeConverter.php └── TypeConverterInterface.php └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class Configuration implements ConfigurationInterface 12 | { 13 | /** 14 | * The DEFAULT_PREFIX defines the default request data namespace prefix. 15 | */ 16 | public const DEFAULT_PREFIX = 'App\\RequestData'; 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function getConfigTreeBuilder() 22 | { 23 | $treeBuilder = new TreeBuilder('request_data'); 24 | $rootNode = \method_exists(TreeBuilder::class, 'getRootNode') ? $treeBuilder->getRootNode() : $treeBuilder->root('request_data'); 25 | 26 | $rootNode->children() 27 | ->scalarNode('prefix') 28 | ->defaultValue(self::DEFAULT_PREFIX) 29 | ->end(); 30 | 31 | return $treeBuilder; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DependencyInjection/RequestDataExtension.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class RequestDataExtension extends Extension 14 | { 15 | /** 16 | * {@inheritdoc} 17 | */ 18 | public function load(array $configs, ContainerBuilder $container) 19 | { 20 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 21 | $loader->load('services.yml'); 22 | 23 | $configuration = new Configuration(); 24 | $config = $this->processConfiguration($configuration, $configs); 25 | 26 | $definition = $container->getDefinition('request_data.controller_listener'); 27 | $definition->replaceArgument('$prefix', $config['prefix']); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Event/FinishEvent.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class FinishEvent extends Event 11 | { 12 | /** 13 | * @var object 14 | */ 15 | protected $requestData; 16 | 17 | public function __construct(object $requestData) 18 | { 19 | $this->requestData = $requestData; 20 | } 21 | 22 | /** 23 | * @return object 24 | */ 25 | public function getRequestData(): object 26 | { 27 | return $this->requestData; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /EventListener/ControllerListener.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ControllerListener 16 | { 17 | /** 18 | * @var MapperInterface 19 | */ 20 | private $mapper; 21 | 22 | /** 23 | * @var EventDispatcherInterface 24 | */ 25 | private $dispatcher; 26 | 27 | /** 28 | * @var string 29 | */ 30 | private $prefix; 31 | 32 | public function __construct(MapperInterface $mapper, EventDispatcherInterface $dispatcher, string $prefix) 33 | { 34 | $this->mapper = $mapper; 35 | $this->dispatcher = $dispatcher; 36 | $this->prefix = $prefix; 37 | } 38 | 39 | /** 40 | * @param FilterControllerEvent $event 41 | * 42 | * @throws NotSupportedFormatException 43 | * @throws \ReflectionException 44 | */ 45 | public function onKernelController(FilterControllerEvent $event) 46 | { 47 | $controller = $event->getController(); 48 | if (!\is_array($controller)) { 49 | return; 50 | } 51 | 52 | $controllerClass = new \ReflectionClass($controller[0]); 53 | if ($controllerClass->isAbstract()) { 54 | return; 55 | } 56 | 57 | $parameters = $controllerClass->getMethod($controller[1])->getParameters(); 58 | foreach ($parameters as $parameter) { 59 | $class = $parameter->getClass(); 60 | 61 | if (null !== $class && 0 === \strpos($class->getName(), $this->prefix)) { 62 | $request = $event->getRequest(); 63 | 64 | $object = $class->newInstance(); 65 | 66 | $this->mapper->map($request, $object); 67 | 68 | $request->attributes->set($parameter->getName(), $object); 69 | 70 | $this->dispatcher->dispatch(Events::FINISH, new FinishEvent($object)); 71 | 72 | break; 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Events.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class Events 9 | { 10 | /** 11 | * The FINISH event occurs when request data formation is finished. 12 | * 13 | * @Event("Bilyiv\RequestDataBundle\Event\FinishEvent") 14 | */ 15 | public const FINISH = 'request_data.finish'; 16 | } 17 | -------------------------------------------------------------------------------- /Exception/NotSupportedFormatException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class NotSupportedFormatException extends \Exception 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /Extractor/Extractor.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Extractor implements ExtractorInterface 13 | { 14 | /** 15 | * @var TypeConverterInterface 16 | */ 17 | private $converter; 18 | 19 | public function __construct(TypeConverterInterface $converter) 20 | { 21 | $this->converter = $converter; 22 | } 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function extractData(Request $request, string $format) 28 | { 29 | $method = $request->getMethod(); 30 | 31 | if (Request::METHOD_GET === $method) { 32 | return $this->converter->convert($request->query->all()); 33 | } 34 | 35 | if (Request::METHOD_POST === $method || Request::METHOD_PUT === $method || Request::METHOD_PATCH === $method) { 36 | if (Formats::FORM === $format) { 37 | return $request->files->all() + $request->request->all(); 38 | } 39 | 40 | return $request->getContent(); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function extractFormat(Request $request): ?string 50 | { 51 | if (Request::METHOD_GET === $request->getMethod()) { 52 | return Formats::FORM; 53 | } 54 | 55 | $format = $request->getFormat($request->headers->get('content-type')); 56 | 57 | if (!\in_array($format, static::getSupportedFormats())) { 58 | return null; 59 | } 60 | 61 | return $format; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public static function getSupportedFormats(): array 68 | { 69 | return [Formats::FORM, Formats::JSON, Formats::XML]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Extractor/ExtractorInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface ExtractorInterface extends FormatSupportableInterface 12 | { 13 | /** 14 | * Extract data from the request. 15 | * 16 | * @param Request $request 17 | * @param string $format 18 | * 19 | * @return mixed 20 | */ 21 | public function extractData(Request $request, string $format); 22 | 23 | /** 24 | * Extract format from the request. 25 | * 26 | * @param Request $request 27 | * 28 | * @return string|null 29 | */ 30 | public function extractFormat(Request $request): ?string; 31 | } 32 | -------------------------------------------------------------------------------- /FormatSupportableInterface.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface FormatSupportableInterface 9 | { 10 | /** 11 | * Return supported formats. 12 | * 13 | * @return array 14 | */ 15 | public static function getSupportedFormats(): array; 16 | } 17 | -------------------------------------------------------------------------------- /Formats.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class Formats 9 | { 10 | /** 11 | * The FORM format represents form data content types. 12 | */ 13 | public const FORM = 'form'; 14 | 15 | /** 16 | * The JSON format represents json content types. 17 | */ 18 | public const JSON = 'json'; 19 | 20 | /** 21 | * The XML format represents xml content types. 22 | */ 23 | public const XML = 'xml'; 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 bilyiv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Mapper/Mapper.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class Mapper implements MapperInterface 17 | { 18 | /** 19 | * @var ExtractorInterface 20 | */ 21 | private $extractor; 22 | 23 | /** 24 | * @var SerializerInterface 25 | */ 26 | private $serializer; 27 | 28 | /** 29 | * @var PropertyAccessorInterface 30 | */ 31 | private $propertyAccessor; 32 | 33 | public function __construct( 34 | ExtractorInterface $extractor, 35 | SerializerInterface $serializer, 36 | PropertyAccessorInterface $propertyAccessor 37 | ) { 38 | $this->extractor = $extractor; 39 | $this->serializer = $serializer; 40 | $this->propertyAccessor = $propertyAccessor; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function map(Request $request, object $object): void 47 | { 48 | $format = $this->extractor->extractFormat($request); 49 | $formatSupportable = $object instanceof FormatSupportableInterface; 50 | if (!$format || ($formatSupportable && !\in_array($format, $object::getSupportedFormats()))) { 51 | throw new NotSupportedFormatException(); 52 | } 53 | 54 | $data = $this->extractor->extractData($request, $format); 55 | if (!$data) { 56 | return; 57 | } 58 | 59 | if (Formats::FORM === $format && \is_array($data)) { 60 | $this->mapForm($data, $object); 61 | return; 62 | } 63 | 64 | $this->serializer->deserialize($data, \get_class($object), $format, ['object_to_populate' => $object]); 65 | } 66 | 67 | /** 68 | * @param array $data 69 | * @param object $object 70 | */ 71 | protected function mapForm(array $data, object $object): void 72 | { 73 | foreach ($data as $propertyPath => $propertyValue) { 74 | if ($this->propertyAccessor->isWritable($object, $propertyPath)) { 75 | $this->propertyAccessor->setValue($object, $propertyPath, $propertyValue); 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Mapper/MapperInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface MapperInterface 12 | { 13 | /** 14 | * Map request to certain object. 15 | * 16 | * @param Request $request 17 | * @param object $object 18 | * 19 | * @throws NotSupportedFormatException 20 | */ 21 | public function map(Request $request, object $object): void; 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Request data bundle 2 | 3 | This bundle allows you to represent request data in a structured and useful way by creating request data classes. 4 | 5 | **Features**: 6 | 7 | * Detecting how to extract data depends on request method and `Content-Type` header. 8 | * Representing and normalizing query parameters for the `GET` request method. 9 | * Representing `form`, `json`, `xml` request body for the `POST`, `PUT`, `PATCH` request methods. 10 | * Defining supported formats and throwing exception if the request format is unsupported. 11 | * Dispatching the finish event when request data is ready. 12 | 13 | ## Installation 14 | 15 | Run the following command using [Composer](http://packagist.org): 16 | 17 | ```sh 18 | composer require bilyiv/request-data-bundle 19 | ``` 20 | 21 | ## Configuration 22 | 23 | The default configuration is the following: 24 | 25 | ```yaml 26 | request_data: 27 | prefix: App\RequestData 28 | ``` 29 | 30 | ## Usage 31 | 32 | #### Create a request data class 33 | 34 | ```php 35 | namespace App\RequestData; 36 | 37 | class PostRequestData implements FormatSupportableInterface 38 | { 39 | public const DEFAULT_AUTHOR = 'none'; 40 | 41 | /** 42 | * @var string 43 | */ 44 | public $title; 45 | 46 | /** 47 | * @var string 48 | */ 49 | public $author = self::DEFAULT_AUTHOR; 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public static function getSupportedFormats(): array 55 | { 56 | return [Formats::FORM, Formats::JSON, Formats::XML]; 57 | } 58 | } 59 | ``` 60 | 61 | #### Use it in your controller 62 | 63 | ```php 64 | namespace App\Controller; 65 | 66 | class PostController extends AbstractController 67 | { 68 | /** 69 | * @Route("/", name="action") 70 | */ 71 | public function action(PostRequestData $data) 72 | { 73 | return new JsonResponse($data); 74 | } 75 | } 76 | ``` 77 | 78 | #### Make requests 79 | 80 | All the following requests will return the same json response: 81 | 82 | ```json 83 | { 84 | "title": "Hamlet", 85 | "author": "William Shakespeare" 86 | } 87 | ``` 88 | 89 | `GET` request: 90 | 91 | ```bash 92 | curl -X GET 'https://example.com?title=Hamlet&author=William+Shakespeare' 93 | ``` 94 | 95 | `POST` form request: 96 | 97 | ```bash 98 | curl -X POST 'https://example.com' \ 99 | -H 'Content-Type: application/x-www-form-urlencoded' \ 100 | -d 'title=Hamlet&author=William+Shakespeare' 101 | ``` 102 | 103 | `POST` json request: 104 | 105 | ```bash 106 | curl -X POST 'https://example.com' \ 107 | -H 'Content-Type: application/json' \ 108 | -d '{"title":"Hamlet","author":"William Shakespeare"}' 109 | ``` 110 | 111 | `POST` xml request: 112 | 113 | ```bash 114 | curl -X POST 'https://example.com' \ 115 | -H 'Content-Type: application/xml' \ 116 | -d 'HamletWilliam Shakespeare' 117 | ``` 118 | 119 | `POST` csv request throws an exception because of unsupported format: 120 | 121 | ```bash 122 | curl -X POST 'https://example.com' \ 123 | -H 'Content-Type: application/csv' \ 124 | -d 'Hamlet,William Shakespeare' 125 | ``` 126 | 127 | ## License 128 | 129 | This bundle is released under the MIT license. See the included [LICENSE](LICENSE) file for more information. 130 | -------------------------------------------------------------------------------- /RequestDataBundle.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class RequestDataBundle extends Bundle 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | 6 | request_data.extractor: 7 | class: 'Bilyiv\RequestDataBundle\Extractor\Extractor' 8 | 9 | Bilyiv\RequestDataBundle\Extractor\ExtractorInterface: '@request_data.extractor' 10 | 11 | request_data.mapper: 12 | class: 'Bilyiv\RequestDataBundle\Mapper\Mapper' 13 | 14 | Bilyiv\RequestDataBundle\Mapper\MapperInterface: '@request_data.mapper' 15 | 16 | request_data.type_converter: 17 | class: 'Bilyiv\RequestDataBundle\TypeConverter\TypeConverter' 18 | 19 | Bilyiv\RequestDataBundle\TypeConverter\TypeConverterInterface: '@request_data.type_converter' 20 | 21 | request_data.controller_listener: 22 | class: 'Bilyiv\RequestDataBundle\EventListener\ControllerListener' 23 | arguments: 24 | $prefix: '' 25 | tags: 26 | - {name: 'kernel.event_listener', event: 'kernel.controller'} -------------------------------------------------------------------------------- /Tests/EventListener/ControllerListenerTest.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class ControllerListenerTest extends TestCase 20 | { 21 | /** 22 | * @var ControllerListener 23 | */ 24 | private $controllerListener; 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function setUp() 30 | { 31 | $mapper = $this->getMockBuilder(MapperInterface::class) 32 | ->disableOriginalConstructor() 33 | ->getMock(); 34 | 35 | $mapper 36 | ->method('map') 37 | ->willReturnCallback(function (Request $request, object $object) { 38 | $object->foo = 'bar'; 39 | }); 40 | 41 | $dispatcher = $this->getMockBuilder(EventDispatcherInterface::class) 42 | ->disableOriginalConstructor() 43 | ->getMock(); 44 | 45 | $this->controllerListener = new ControllerListener( 46 | $mapper, 47 | $dispatcher, 48 | 'Bilyiv\RequestDataBundle\Tests\Fixtures' 49 | ); 50 | } 51 | 52 | /** 53 | * Tests if listener do nothing when there is wrong controller. 54 | */ 55 | public function testOnKernelControllerWithWrongController() 56 | { 57 | $filterControllerEvent = $this->getMockBuilder(FilterControllerEvent::class) 58 | ->disableOriginalConstructor() 59 | ->getMock(); 60 | 61 | $filterControllerEvent 62 | ->expects($this->once()) 63 | ->method('getController') 64 | ->willReturn(null); 65 | 66 | $filterControllerEvent 67 | ->expects($this->never()) 68 | ->method('getRequest'); 69 | 70 | $result = $this->controllerListener->onKernelController($filterControllerEvent); 71 | 72 | $this->assertNull($result); 73 | } 74 | 75 | /** 76 | * Tests if listener do nothing when there is an abstract controller. 77 | */ 78 | public function testOnKernelControllerWithAbstractController() 79 | { 80 | $filterControllerEvent = $this->getMockBuilder(FilterControllerEvent::class) 81 | ->disableOriginalConstructor() 82 | ->getMock(); 83 | 84 | $filterControllerEvent 85 | ->expects($this->once()) 86 | ->method('getController') 87 | ->willReturn([TestAbstractController::class]); 88 | 89 | $filterControllerEvent 90 | ->expects($this->never()) 91 | ->method('getRequest'); 92 | 93 | $result = $this->controllerListener->onKernelController($filterControllerEvent); 94 | 95 | $this->assertNull($result); 96 | } 97 | 98 | /** 99 | * Tests if listener do nothing when there is a controller without injected request data class. 100 | */ 101 | public function testOnKernelControllerWithoutInjectedRequestData() 102 | { 103 | $filterControllerEvent = $this->getMockBuilder(FilterControllerEvent::class) 104 | ->disableOriginalConstructor() 105 | ->getMock(); 106 | 107 | $filterControllerEvent 108 | ->expects($this->once()) 109 | ->method('getController') 110 | ->willReturn([TestController::class, 'index']); 111 | 112 | $filterControllerEvent 113 | ->expects($this->never()) 114 | ->method('getRequest'); 115 | 116 | $result = $this->controllerListener->onKernelController($filterControllerEvent); 117 | 118 | $this->assertNull($result); 119 | } 120 | 121 | /** 122 | * Tests if listener do nothing when there is a controller with injected request data class. 123 | */ 124 | public function testOnKernelControllerWithInjectedRequestData() 125 | { 126 | $request = new Request(); 127 | 128 | $filterControllerEvent = $this->getMockBuilder(FilterControllerEvent::class) 129 | ->disableOriginalConstructor() 130 | ->getMock(); 131 | 132 | $filterControllerEvent 133 | ->expects($this->once()) 134 | ->method('getController') 135 | ->willReturn([TestRequestDataController::class, 'index']); 136 | 137 | $filterControllerEvent 138 | ->expects($this->once()) 139 | ->method('getRequest') 140 | ->willReturn($request); 141 | 142 | $result = $this->controllerListener->onKernelController($filterControllerEvent); 143 | 144 | $this->assertNull($result); 145 | $this->assertEquals(1, $request->attributes->count()); 146 | $this->assertInstanceOf(TestRequestData::class, $request->attributes->get('data')); 147 | $this->assertEquals('bar', $request->attributes->get('data')->foo); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/Extractor/ExtractorTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ExtractorTest extends TestCase 16 | { 17 | /** 18 | * @var ExtractorInterface 19 | */ 20 | private $extractor; 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public function setUp() 26 | { 27 | $converter = $this->getMockBuilder(TypeConverterInterface::class) 28 | ->disableOriginalConstructor() 29 | ->getMock(); 30 | 31 | $converter 32 | ->method('convert') 33 | ->willReturn(['get' => 'request']); 34 | 35 | $this->extractor = new Extractor($converter); 36 | } 37 | 38 | /** 39 | * Test if extractor implements necessary interface. 40 | */ 41 | public function testInterface() 42 | { 43 | $this->assertInstanceOf(ExtractorInterface::class, $this->extractor); 44 | } 45 | 46 | /** 47 | * Test if extractor returns form data from GET request. 48 | */ 49 | public function testExtractFormDataFromGetRequest() 50 | { 51 | $request = Request::create('/', Request::METHOD_GET, ['get' => 'request']); 52 | 53 | $this->assertEquals(['get' => 'request'], $this->extractor->extractData($request, Formats::FORM)); 54 | } 55 | 56 | /** 57 | * Test if extractor returns json data from POST request. 58 | */ 59 | public function testExtractJsonDataFromPostRequest() 60 | { 61 | $request = Request::create('/', Request::METHOD_POST, [], [], [], [], '{"post":"request"}'); 62 | $request->headers->set('content-type', 'application/json'); 63 | 64 | $this->assertEquals('{"post":"request"}', $this->extractor->extractData($request, Formats::JSON)); 65 | } 66 | 67 | /** 68 | * Test if extractor returns json data from PUT request. 69 | */ 70 | public function testExtractJsonDataFromPutRequest() 71 | { 72 | $request = Request::create('/', Request::METHOD_POST, [], [], [], [], '{"put":"request"}'); 73 | $request->headers->set('content-type', 'application/json'); 74 | 75 | $this->assertEquals('{"put":"request"}', $this->extractor->extractData($request, Formats::JSON)); 76 | } 77 | 78 | /** 79 | * Test if extractor returns json data from PATCH request. 80 | */ 81 | public function testExtractJsonDataFromPatchRequest() 82 | { 83 | $request = Request::create('/', Request::METHOD_PATCH, [], [], [], [], '{"patch":"request"}'); 84 | $request->headers->set('content-type', 'application/json'); 85 | 86 | $this->assertEquals('{"patch":"request"}', $this->extractor->extractData($request, Formats::JSON)); 87 | } 88 | 89 | /** 90 | * Test if extractor returns xml data from POST request. 91 | */ 92 | public function testExtractXmlDataFromPostRequest() 93 | { 94 | $request = Request::create('/', Request::METHOD_POST, [], [], [], [], 'request'); 95 | $request->headers->set('content-type', 'application/xml'); 96 | 97 | $this->assertEquals('request', $this->extractor->extractData($request, Formats::XML)); 98 | } 99 | 100 | /** 101 | * Test if extractor returns xml data from PUT request. 102 | */ 103 | public function testExtractXmlDataFromPutRequest() 104 | { 105 | $request = Request::create('/', Request::METHOD_POST, [], [], [], [], 'request'); 106 | $request->headers->set('content-type', 'application/xml'); 107 | 108 | $this->assertEquals('request', $this->extractor->extractData($request, Formats::XML)); 109 | } 110 | 111 | /** 112 | * Test if extractor returns xml data from PATCH request. 113 | */ 114 | public function testExtractXmlDataFromPatchRequest() 115 | { 116 | $request = Request::create('/', Request::METHOD_PATCH, [], [], [], [], 'request'); 117 | $request->headers->set('content-type', 'application/xml'); 118 | 119 | $this->assertEquals('request', $this->extractor->extractData($request, Formats::XML)); 120 | } 121 | 122 | /** 123 | * Test if extractor returns form format from GET request. 124 | */ 125 | public function testExtractFormFormatFromGetRequest() 126 | { 127 | $request = Request::create('/', Request::METHOD_GET); 128 | 129 | $this->assertEquals(Formats::FORM, $this->extractor->extractFormat($request)); 130 | } 131 | 132 | /** 133 | * Test if extractor returns form format from POST request. 134 | */ 135 | public function testExtractFormFormatFromPostRequest() 136 | { 137 | $request = Request::create('/', Request::METHOD_POST); 138 | 139 | $this->assertEquals(Formats::FORM, $this->extractor->extractFormat($request)); 140 | } 141 | 142 | /** 143 | * Test if extractor returns form format from PUT request. 144 | */ 145 | public function testExtractFormFormatFromPutRequest() 146 | { 147 | $request = Request::create('/', Request::METHOD_PUT); 148 | 149 | $this->assertEquals(Formats::FORM, $this->extractor->extractFormat($request)); 150 | } 151 | 152 | /** 153 | * Test if extractor returns json format from POST request. 154 | */ 155 | public function testExtractJsonFormatFromPostRequest() 156 | { 157 | $request = Request::create('/', Request::METHOD_POST); 158 | $request->headers->set('content-type', 'application/json'); 159 | 160 | $this->assertEquals(Formats::JSON, $this->extractor->extractFormat($request)); 161 | } 162 | 163 | /** 164 | * Test if extractor returns json format from PUT request. 165 | */ 166 | public function testExtractJsonFormatFromPutRequest() 167 | { 168 | $request = Request::create('/', Request::METHOD_PUT); 169 | $request->headers->set('content-type', 'application/json'); 170 | 171 | $this->assertEquals(Formats::JSON, $this->extractor->extractFormat($request)); 172 | } 173 | 174 | /** 175 | * Test if extractor returns json format from PATCH request. 176 | */ 177 | public function testExtractJsonFormatFromPatchRequest() 178 | { 179 | $request = Request::create('/', Request::METHOD_PATCH); 180 | $request->headers->set('content-type', 'application/json'); 181 | 182 | $this->assertEquals(Formats::JSON, $this->extractor->extractFormat($request)); 183 | } 184 | 185 | /** 186 | * Test if extractor returns xml format from POST request. 187 | */ 188 | public function testExtractXmlFormatFromPostRequest() 189 | { 190 | $request = Request::create('/', Request::METHOD_POST); 191 | $request->headers->set('content-type', 'application/xml'); 192 | 193 | $this->assertEquals(Formats::XML, $this->extractor->extractFormat($request)); 194 | } 195 | 196 | /** 197 | * Test if extractor returns xml format from PUT request. 198 | */ 199 | public function testExtractXmlFormatFromPutRequest() 200 | { 201 | $request = Request::create('/', Request::METHOD_PUT); 202 | $request->headers->set('content-type', 'application/xml'); 203 | 204 | $this->assertEquals(Formats::XML, $this->extractor->extractFormat($request)); 205 | } 206 | 207 | /** 208 | * Test if extractor returns xml format from PATCH request. 209 | */ 210 | public function testExtractXmlFormatFromPatchRequest() 211 | { 212 | $request = Request::create('/', Request::METHOD_PATCH); 213 | $request->headers->set('content-type', 'application/xml'); 214 | 215 | $this->assertEquals(Formats::XML, $this->extractor->extractFormat($request)); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Tests/Fixtures/TestAbstractController.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | abstract class TestAbstractController 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /Tests/Fixtures/TestController.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class TestController extends TestAbstractController 11 | { 12 | public function index(Request $request) 13 | { 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/Fixtures/TestFormatSupportableRequestData.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class TestFormatSupportableRequestData implements FormatSupportableInterface 12 | { 13 | /** 14 | * @var string|null 15 | */ 16 | public $foo; 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public static function getSupportedFormats(): array 22 | { 23 | return [Formats::JSON]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/Fixtures/TestRequestData.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class TestRequestData 9 | { 10 | /** 11 | * @var string|null 12 | */ 13 | public $foo; 14 | } 15 | -------------------------------------------------------------------------------- /Tests/Fixtures/TestRequestDataController.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class TestRequestDataController extends TestAbstractController 9 | { 10 | public function index(TestRequestData $data) 11 | { 12 | return $data; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/Mapper/MapperTest.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class MapperTest extends TestCase 22 | { 23 | /** 24 | * @var ExtractorInterface|MockObject 25 | */ 26 | private $extractor; 27 | 28 | /** 29 | * @var SerializerInterface|MockObject 30 | */ 31 | private $serializer; 32 | 33 | /** 34 | * @var MapperInterface|MockObject 35 | */ 36 | private $mapper; 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function setUp() 42 | { 43 | $this->extractor = $this->getMockBuilder(ExtractorInterface::class) 44 | ->disableOriginalConstructor() 45 | ->getMock(); 46 | 47 | $this->serializer = $this->getMockBuilder(SerializerInterface::class) 48 | ->disableOriginalConstructor() 49 | ->getMock(); 50 | 51 | $this->mapper = new Mapper($this->extractor, $this->serializer, new PropertyAccessor()); 52 | } 53 | 54 | /** 55 | * Test if mapper implements necessary interfaces. 56 | */ 57 | public function testInterface() 58 | { 59 | $this->assertInstanceOf(MapperInterface::class, $this->mapper); 60 | } 61 | 62 | /** 63 | * Tests if mapper throws unsupported format exception. 64 | */ 65 | public function testUnsupportedRequestFormatException() 66 | { 67 | $this->extractor 68 | ->method('extractFormat') 69 | ->willReturn(Formats::FORM); 70 | 71 | $this->expectException(NotSupportedFormatException::class); 72 | 73 | $this->mapper->map(new Request(), new TestFormatSupportableRequestData()); 74 | } 75 | 76 | /** 77 | * Test if mapper handles form requests correctly. 78 | */ 79 | public function testFormRequestMapping() 80 | { 81 | $this->extractor 82 | ->method('extractFormat') 83 | ->willReturn(Formats::FORM); 84 | 85 | $this->extractor 86 | ->method('extractData') 87 | ->willReturn(['foo' => 'bar']); 88 | 89 | $this->serializer 90 | ->expects($this->never()) 91 | ->method('deserialize') 92 | ->willReturn(null); 93 | 94 | $requestData = new TestRequestData(); 95 | 96 | $this->mapper->map(new Request(), $requestData); 97 | 98 | $this->assertEquals('bar', $requestData->foo); 99 | } 100 | 101 | /** 102 | * Test if mapper handles not form requests correctly. 103 | */ 104 | public function testNotFormRequestMapping() 105 | { 106 | $this->extractor 107 | ->method('extractFormat') 108 | ->willReturn(Formats::JSON); 109 | 110 | $this->extractor 111 | ->method('extractData') 112 | ->willReturn('{"foo":"bar"}'); 113 | 114 | $this->serializer 115 | ->expects($this->once()) 116 | ->method('deserialize') 117 | ->willReturn(null); 118 | 119 | $this->mapper->map(new Request(), new TestRequestData()); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Tests/TypeConverter/TypeConverterTest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class TypeConverterTest extends TestCase 13 | { 14 | /** 15 | * @var TypeConverter 16 | */ 17 | private $converter; 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function setUp() 23 | { 24 | $this->converter = new TypeConverter(); 25 | } 26 | 27 | /** 28 | * Test if type converter implements necessary interface. 29 | */ 30 | public function testInterface() 31 | { 32 | $this->assertInstanceOf(TypeConverterInterface::class, $this->converter); 33 | } 34 | 35 | /** 36 | * Test if type converter converts scalar types correctly. 37 | */ 38 | public function testConvertScalars() 39 | { 40 | $this->assertNull($this->converter->convert('')); 41 | $this->assertNull($this->converter->convert('null')); 42 | $this->assertTrue($this->converter->convert('true')); 43 | $this->assertFalse($this->converter->convert('false')); 44 | $this->assertIsInt($this->converter->convert('10')); 45 | $this->assertIsFloat($this->converter->convert('10.1')); 46 | } 47 | 48 | /** 49 | * Test if type converter converts array correctly. 50 | */ 51 | public function testConvertArray() 52 | { 53 | $this->assertEquals([null, 10.1], $this->converter->convert(['', '10.1'])); 54 | $this->assertEquals([10 => [true]], $this->converter->convert([10 => ['true']])); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TypeConverter/TypeConverter.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class TypeConverter implements TypeConverterInterface 9 | { 10 | /** 11 | * {@inheritdoc} 12 | */ 13 | public function convert($value) 14 | { 15 | if (\is_string($value)) { 16 | $value = $this->convertString($value); 17 | } 18 | 19 | if (\is_array($value)) { 20 | $value = $this->convertArray($value); 21 | } 22 | 23 | return $value; 24 | } 25 | 26 | /** 27 | * @param array $values 28 | * 29 | * @return array 30 | */ 31 | protected function convertArray(array $values) 32 | { 33 | foreach ($values as &$value) { 34 | $value = $this->convert($value); 35 | } 36 | 37 | return $values; 38 | } 39 | 40 | /** 41 | * @param string $value 42 | * 43 | * @return mixed 44 | */ 45 | protected function convertString(string $value) 46 | { 47 | if ('' === $value || 'null' === $value) { 48 | return null; 49 | } 50 | if ('true' === $value) { 51 | return true; 52 | } 53 | if ('false' === $value) { 54 | return false; 55 | } 56 | if (\preg_match('/^-?\d+$/', $value)) { 57 | return (int) $value; 58 | } 59 | if (\preg_match('/^-?\d+(\.\d+)?$/', $value)) { 60 | return (float) $value; 61 | } 62 | 63 | return $value; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /TypeConverter/TypeConverterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface TypeConverterInterface 9 | { 10 | /** 11 | * @param $value 12 | * 13 | * @return mixed 14 | */ 15 | public function convert($value); 16 | } 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilyiv/request-data-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Represents request data in a structured and useful way.", 5 | "keywords": ["request", "data", "api", "rest", "json"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Vladyslav Bilyi", 10 | "email": "beliyvladislav@gmail.com" 11 | } 12 | ], 13 | "prefer-stable": true, 14 | "require": { 15 | "php": "^7.2", 16 | "symfony/config": "~3.4|~4.0", 17 | "symfony/dependency-injection": "~3.4|~4.0", 18 | "symfony/http-kernel": "~3.4|~4.0", 19 | "symfony/serializer": "~3.4|~4.0", 20 | "symfony/event-dispatcher": "~3.4|~4.0", 21 | "symfony/property-access": "~3.4|~4.0" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^7" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Bilyiv\\RequestDataBundle\\": "" 29 | }, 30 | "exclude-from-classmap": [ 31 | "/Tests/" 32 | ] 33 | }, 34 | "extra": { 35 | "branch-alias": { 36 | "dev-master": "1.0.x-dev" 37 | } 38 | } 39 | } 40 | --------------------------------------------------------------------------------