├── tests ├── Fixtures │ ├── uploaded_file │ └── ForeignAttribute.php ├── Attribute │ ├── RequestHeaderTest.php │ ├── QueryParamTest.php │ ├── RequestBodyTest.php │ ├── RequestParamTest.php │ └── RequestCookieTest.php ├── Http │ ├── UnsupportedMediaTypeExceptionTest.php │ ├── NotAcceptableMediaTypeExceptionTest.php │ ├── ContentDispositionDescriptorTest.php │ ├── MediaTypeDescriptorTest.php │ ├── MessageBodyMapperManagerTest.php │ └── EntityResponseTest.php ├── Controller │ ├── ControllerTraitTest.php │ └── ArgumentResolver │ │ ├── QueryParamValueResolverTest.php │ │ ├── RequestCookieValueResolverTest.php │ │ ├── RequestHeaderValueResolverTest.php │ │ ├── RequestParamValueResolverTest.php │ │ ├── QueryParamsValueResolverTest.php │ │ ├── AbstractNamedValueArgumentValueResolverTest.php │ │ └── RequestBodyValueResolverTest.php ├── EventListener │ ├── ExceptionListenerTest.php │ └── EntityResponseListenerTest.php ├── Filesystem │ └── TmpFileUtilsTest.php ├── Serializer │ └── ObjectAlreadyOfTypeDenormalizerTest.php ├── Converter │ ├── BuiltinTypeSafeConverterTest.php │ ├── ConverterManagerTest.php │ └── SerializerObjectConverterAdapterTest.php └── Mapper │ └── SerializerMapperAdapterTest.php ├── .gitignore ├── src ├── Mapper │ ├── MalformedDataException.php │ ├── MapperInterface.php │ └── SerializerMapperAdapter.php ├── Attribute │ ├── QueryParams.php │ ├── NamedValue.php │ ├── RequestBody.php │ ├── RequestHeader.php │ ├── QueryParam.php │ ├── RequestParam.php │ └── RequestCookie.php ├── Converter │ ├── TypeConversionException.php │ ├── ConverterInterface.php │ ├── SerializerConverterAdapter.php │ ├── ConverterManager.php │ └── BuiltinTypeSafeConverter.php ├── Controller │ ├── ControllerTrait.php │ └── ArgumentResolver │ │ ├── QueryParamValueResolver.php │ │ ├── RequestCookieValueResolver.php │ │ ├── ArgumentValueResolverInterface.php │ │ ├── RequestHeaderValueResolver.php │ │ ├── NamedValueArgument.php │ │ ├── RequestParamValueResolver.php │ │ ├── QueryParamsValueResolver.php │ │ ├── AbstractNamedValueArgumentValueResolver.php │ │ └── RequestBodyValueResolver.php ├── JungiFrameworkExtraBundle.php ├── DependencyInjection │ ├── Configuration.php │ ├── Compiler │ │ ├── RegisterConvertersPass.php │ │ └── RegisterMessageBodyMappersPass.php │ └── JungiFrameworkExtraExtension.php ├── Http │ ├── UnsupportedMediaTypeException.php │ ├── NotAcceptableMediaTypeException.php │ ├── MessageBodyMapperManager.php │ ├── ContentDispositionDescriptor.php │ ├── MediaTypeDescriptor.php │ └── EntityResponse.php ├── Serializer │ └── ObjectAlreadyOfTypeDenormalizer.php ├── Filesystem │ └── TmpFileUtils.php └── EventListener │ ├── EntityResponseListener.php │ └── ExceptionListener.php ├── phpunit.xml.dist ├── .github └── workflows │ └── continuous-integration.yml ├── LICENSE ├── composer.json ├── config ├── serializer.xml ├── services.xml └── attributes.xml ├── README.md └── CHANGELOG.md /tests/Fixtures/uploaded_file: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /vendor 3 | /composer.lock 4 | -------------------------------------------------------------------------------- /tests/Fixtures/ForeignAttribute.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class MalformedDataException extends \InvalidArgumentException 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Attribute/QueryParams.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class QueryParams 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /src/Converter/TypeConversionException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class TypeConversionException extends \InvalidArgumentException 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Attribute/NamedValue.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * @internal 9 | */ 10 | interface NamedValue 11 | { 12 | public function name(): ?string; 13 | } 14 | -------------------------------------------------------------------------------- /src/Attribute/RequestBody.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class RequestBody 10 | { 11 | private ?string $type; 12 | 13 | public function __construct(?string $type = null) 14 | { 15 | $this->type = $type; 16 | } 17 | 18 | public function type(): ?string 19 | { 20 | return $this->type; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Attribute/RequestHeader.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class RequestHeader implements NamedValue 10 | { 11 | private string $name; 12 | 13 | public function __construct(string $name) 14 | { 15 | $this->name = $name; 16 | } 17 | 18 | public function name(): string 19 | { 20 | return $this->name; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Converter/ConverterInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface ConverterInterface 11 | { 12 | /** 13 | * Converts a value to the given type. 14 | * 15 | * @throws \InvalidArgumentException On non supported type 16 | * @throws TypeConversionException 17 | */ 18 | public function convert(mixed $value, string $type): mixed; 19 | } 20 | -------------------------------------------------------------------------------- /src/Attribute/QueryParam.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class QueryParam implements NamedValue 10 | { 11 | private ?string $name; 12 | 13 | public function __construct(?string $name = null) 14 | { 15 | $this->name = $name; 16 | } 17 | 18 | public function name(): ?string 19 | { 20 | return $this->name; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Attribute/RequestParam.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class RequestParam implements NamedValue 10 | { 11 | private ?string $name; 12 | 13 | public function __construct(?string $name = null) 14 | { 15 | $this->name = $name; 16 | } 17 | 18 | public function name(): ?string 19 | { 20 | return $this->name; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Attribute/RequestHeaderTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class RequestHeaderTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $annotation = new RequestHeader('foo'); 17 | $this->assertEquals('foo', $annotation->name()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Attribute/RequestCookie.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class RequestCookie implements NamedValue 10 | { 11 | private ?string $name; 12 | 13 | public function __construct(?string $name = null) 14 | { 15 | $this->name = $name; 16 | } 17 | 18 | public function name(): ?string 19 | { 20 | return $this->name; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/Attribute/QueryParamTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class QueryParamTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $annotation = new QueryParam(); 17 | $this->assertNull($annotation->name()); 18 | 19 | $annotation = new QueryParam('foo'); 20 | $this->assertEquals('foo', $annotation->name()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Attribute/RequestBodyTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class RequestBodyTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $annotation = new RequestBody(); 17 | $this->assertNull($annotation->type()); 18 | 19 | $annotation = new RequestBody('int[]'); 20 | $this->assertEquals('int[]', $annotation->type()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Attribute/RequestParamTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class RequestParamTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $annotation = new RequestParam(); 17 | $this->assertNull($annotation->name()); 18 | 19 | $annotation = new RequestParam('foo'); 20 | $this->assertEquals('foo', $annotation->name()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Controller/ControllerTrait.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | trait ControllerTrait 11 | { 12 | /** 13 | * Returns an EntityResponse with the given entity that is mapped 14 | * to the selected content type using the content negotiation. 15 | */ 16 | protected function entity(mixed $entity, int $status = 200, array $headers = []): EntityResponse 17 | { 18 | return new EntityResponse($entity, $status, $headers); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Attribute/RequestCookieTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class RequestCookieTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $annotation = new RequestCookie(); 17 | $this->assertNull($annotation->name()); 18 | 19 | $annotation = new RequestCookie('foo'); 20 | $this->assertEquals('foo', $annotation->name()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/QueryParamValueResolver.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class QueryParamValueResolver extends AbstractNamedValueArgumentValueResolver 12 | { 13 | protected static string $attributeClass = QueryParam::class; 14 | 15 | protected function getArgumentValue(NamedValueArgument $argument, Request $request): string|int|float|bool|null 16 | { 17 | return $request->query->get($argument->getName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/RequestCookieValueResolver.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class RequestCookieValueResolver extends AbstractNamedValueArgumentValueResolver 12 | { 13 | protected static string $attributeClass = RequestCookie::class; 14 | 15 | protected function getArgumentValue(NamedValueArgument $argument, Request $request): string|int|float|bool|null 16 | { 17 | return $request->cookies->get($argument->getName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/ArgumentValueResolverInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface MapperInterface 11 | { 12 | /** 13 | * Maps from text data to type. 14 | * 15 | * @throws \InvalidArgumentException On non supported data 16 | * @throws MalformedDataException 17 | */ 18 | public function mapFrom(string $data, string $type): mixed; 19 | 20 | /** 21 | * Maps PHP data to text. 22 | * 23 | * @throws \InvalidArgumentException On non supported data 24 | */ 25 | public function mapTo(mixed $data): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/JungiFrameworkExtraBundle.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class JungiFrameworkExtraBundle extends Bundle 14 | { 15 | public function build(ContainerBuilder $container): void 16 | { 17 | $container->addCompilerPass(new RegisterMessageBodyMappersPass()); 18 | $container->addCompilerPass(new RegisterConvertersPass()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Http/UnsupportedMediaTypeExceptionTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UnsupportedMediaTypeExceptionTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $e = new UnsupportedMediaTypeException('application/xml', 'unsupported'); 17 | $this->assertEquals('application/xml', $e->getMediaType()); 18 | $this->assertEquals('unsupported', $e->getMessage()); 19 | 20 | $e = UnsupportedMediaTypeException::mapperNotRegistered('application/json'); 21 | $this->assertEquals('application/json', $e->getMediaType()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/RequestHeaderValueResolver.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class RequestHeaderValueResolver extends AbstractNamedValueArgumentValueResolver 12 | { 13 | protected static string $attributeClass = RequestHeader::class; 14 | 15 | protected function getArgumentValue(NamedValueArgument $argument, Request $request): string|array|null 16 | { 17 | if ('array' === $argument->getType()) { 18 | return $request->headers->all($argument->getName()); 19 | } 20 | 21 | return $request->headers->get($argument->getName()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class Configuration implements ConfigurationInterface 12 | { 13 | public function getConfigTreeBuilder(): TreeBuilder 14 | { 15 | $builder = new TreeBuilder('jungi_framework_extra'); 16 | $rootNode = $builder->getRootNode(); 17 | 18 | $rootNode 19 | ->children() 20 | ->booleanNode('serializer')->defaultTrue()->end() 21 | ->scalarNode('default_content_type')->defaultValue('application/json')->end() 22 | ->end(); 23 | 24 | return $builder; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Http/UnsupportedMediaTypeException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class UnsupportedMediaTypeException extends \RuntimeException 9 | { 10 | protected string $mediaType; 11 | 12 | public static function mapperNotRegistered(string $mediaType): static 13 | { 14 | return new static($mediaType, sprintf('No mapper is registered for media type "%s".', $mediaType)); 15 | } 16 | 17 | /** 18 | * @param int $code 19 | */ 20 | public function __construct(string $mediaType, string $message, $code = 0, \Throwable $previous = null) 21 | { 22 | parent::__construct($message, $code, $previous); 23 | 24 | $this->mediaType = $mediaType; 25 | } 26 | 27 | public function getMediaType(): string 28 | { 29 | return $this->mediaType; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Controller/ControllerTraitTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ControllerTraitTest extends TestCase 12 | { 13 | /** @test */ 14 | public function entity() 15 | { 16 | $controller = new TestController(); 17 | $response = $controller->entity('foo', 201, ['Foo' => 'bar']); 18 | 19 | $this->assertEquals('foo', $response->getEntity()); 20 | $this->assertEquals(201, $response->getStatusCode()); 21 | $this->assertSame('bar', $response->headers->get('Foo')); 22 | } 23 | } 24 | 25 | class TestController 26 | { 27 | use ControllerTrait; 28 | 29 | public function __call(string $method, array $args): mixed 30 | { 31 | return $this->{$method}(...$args); 32 | } 33 | } -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/NamedValueArgument.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | final class NamedValueArgument 13 | { 14 | private string $name; 15 | private ?string $type; 16 | private NamedValue $attribute; 17 | 18 | public function __construct(string $name, ?string $type, NamedValue $attribute) 19 | { 20 | $this->name = $name; 21 | $this->type = $type; 22 | $this->attribute = $attribute; 23 | } 24 | 25 | public function getName(): string 26 | { 27 | return $this->name; 28 | } 29 | 30 | public function getType(): ?string 31 | { 32 | return $this->type; 33 | } 34 | 35 | public function getAttribute(): NamedValue 36 | { 37 | return $this->attribute; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Http/NotAcceptableMediaTypeExceptionTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class NotAcceptableMediaTypeExceptionTest extends TestCase 12 | { 13 | /** @test */ 14 | public function create() 15 | { 16 | $expectedNotAcceptable = ['text/xml', 'text/csv']; 17 | $expectedSupported = ['application/xml', 'application/json']; 18 | $expectedMessage = 'not acceptable'; 19 | 20 | $e = new NotAcceptableMediaTypeException($expectedNotAcceptable, $expectedSupported, $expectedMessage); 21 | 22 | $this->assertEquals($expectedNotAcceptable, $e->getNotAcceptableMediaTypes()); 23 | $this->assertEquals($expectedSupported, $e->getSupportedMediaTypes()); 24 | $this->assertEquals($expectedMessage, $e->getMessage()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/RequestParamValueResolver.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class RequestParamValueResolver extends AbstractNamedValueArgumentValueResolver 13 | { 14 | protected static string $attributeClass = RequestParam::class; 15 | 16 | /** @return string|int|float|bool|UploadedFile[]|UploadedFile|null */ 17 | protected function getArgumentValue(NamedValueArgument $argument, Request $request): string|int|float|bool|array|UploadedFile|null 18 | { 19 | if ($this !== $result = $request->files->get($argument->getName(), $this)) { 20 | return $result; 21 | } 22 | 23 | return $request->request->get($argument->getName()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: "ubuntu-latest" 8 | 9 | strategy: 10 | matrix: 11 | php-version: 12 | - 8.0 13 | - 8.1 14 | - 8.2 15 | symfony-version: 16 | - "" 17 | 18 | steps: 19 | - uses: "actions/checkout@v2" 20 | 21 | - name: "Setup PHP" 22 | uses: "shivammathur/setup-php@v2" 23 | with: 24 | php-version: "${{ matrix.php-version }}" 25 | 26 | - name: "Install Symfony Flex" 27 | run: | 28 | composer global require --no-progress --no-scripts --no-plugins symfony/flex 29 | composer global config --no-plugins allow-plugins.symfony/flex true 30 | 31 | - name: "Install dependencies using Composer" 32 | env: 33 | SYMFONY_REQUIRE: "${{ matrix.symfony-version }}" 34 | run: "composer install --no-interaction --no-progress" 35 | 36 | - name: "Run PHPUnit" 37 | run: "bin/phpunit" 38 | -------------------------------------------------------------------------------- /src/Converter/SerializerConverterAdapter.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class SerializerConverterAdapter implements ConverterInterface 12 | { 13 | private DenormalizerInterface $denormalizer; 14 | private array $context; 15 | 16 | public function __construct(DenormalizerInterface $denormalizer, array $context = []) 17 | { 18 | $this->denormalizer = $denormalizer; 19 | $this->context = $context; 20 | } 21 | 22 | public function convert(mixed $value, string $type): mixed 23 | { 24 | try { 25 | return $this->denormalizer->denormalize($value, $type, null, $this->context); 26 | } catch (NotNormalizableValueException | \TypeError $e) { 27 | throw new TypeConversionException($e->getMessage(), 0, $e); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Piotr Kugla 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 | -------------------------------------------------------------------------------- /src/Serializer/ObjectAlreadyOfTypeDenormalizer.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class ObjectAlreadyOfTypeDenormalizer implements DenormalizerInterface 12 | { 13 | public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed 14 | { 15 | if (!is_object($data)) { 16 | throw new InvalidArgumentException(sprintf('Data must be of object type, given "%s".', get_debug_type($data))); 17 | } 18 | if ($type !== get_class($data)) { 19 | throw new InvalidArgumentException(sprintf('Data expected to be of type "%s", given "%s".', $type, get_debug_type($data))); 20 | } 21 | 22 | return $data; 23 | } 24 | 25 | public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool 26 | { 27 | return is_object($data) && $type === get_class($data); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/QueryParamValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class QueryParamValueResolverTest extends TestCase 16 | { 17 | /** @test */ 18 | public function argumentValueIsResolved() 19 | { 20 | $converter = $this->createMock(ConverterInterface::class); 21 | $resolver = new QueryParamValueResolver($converter); 22 | 23 | $request = new Request([ 24 | 'foo' => 'bar' 25 | ]); 26 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 27 | new QueryParam() 28 | ]); 29 | 30 | $values = $resolver->resolve($request, $argument); 31 | 32 | $this->assertCount(1, $values); 33 | $this->assertEquals('bar', $values[0]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Http/NotAcceptableMediaTypeException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class NotAcceptableMediaTypeException extends \RuntimeException 9 | { 10 | /** @var string[] */ 11 | protected array $notAcceptableMediaTypes; 12 | 13 | /** @var string[] */ 14 | protected array $supportedMediaTypes; 15 | 16 | /** 17 | * @param string[] $notAcceptableMediaTypes 18 | * @param string[] $supportedMediaTypes 19 | */ 20 | public function __construct(array $notAcceptableMediaTypes, array $supportedMediaTypes, string $message, int $code = 0, \Throwable $previous = null) 21 | { 22 | parent::__construct($message, $code, $previous); 23 | 24 | $this->notAcceptableMediaTypes = $notAcceptableMediaTypes; 25 | $this->supportedMediaTypes = $supportedMediaTypes; 26 | } 27 | 28 | /** 29 | * @return string[] 30 | */ 31 | public function getNotAcceptableMediaTypes(): array 32 | { 33 | return $this->notAcceptableMediaTypes; 34 | } 35 | 36 | /** 37 | * @return string[] 38 | */ 39 | public function getSupportedMediaTypes(): array 40 | { 41 | return $this->supportedMediaTypes; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Filesystem/TmpFileUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * @internal 9 | */ 10 | final class TmpFileUtils 11 | { 12 | private static array $resources = []; 13 | 14 | public static function fromData(string $data, string $mimeType = 'application/octet-stream'): string 15 | { 16 | $resource = @fopen(sprintf('data:%s;base64,%s', $mimeType, base64_encode($data)), 'r'); 17 | if (!$resource) { 18 | throw new \RuntimeException(error_get_last()['message']); 19 | } 20 | 21 | return self::fromResource($resource); 22 | } 23 | 24 | public static function fromResource($resource): string 25 | { 26 | if (!is_resource($resource)) { 27 | throw new \InvalidArgumentException('Expected to get a resource.'); 28 | } 29 | 30 | $tmpResource = tmpfile(); 31 | $tmpFilename = stream_get_meta_data($tmpResource)['uri']; 32 | self::$resources[] = $tmpResource; 33 | 34 | stream_copy_to_stream($resource, $tmpResource); 35 | 36 | return $tmpFilename; 37 | } 38 | 39 | public static function removeReferences(): void 40 | { 41 | self::$resources = []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/RequestCookieValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class RequestCookieValueResolverTest extends TestCase 16 | { 17 | /** @test */ 18 | public function argumentValueIsResolved() 19 | { 20 | $converter = $this->createMock(ConverterInterface::class); 21 | $resolver = new RequestCookieValueResolver($converter); 22 | 23 | $request = new Request([], [], [], [ 24 | 'foo' => 'bar' 25 | ]); 26 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 27 | new RequestCookie() 28 | ]); 29 | 30 | $values = $resolver->resolve($request, $argument); 31 | 32 | $this->assertCount(1, $values); 33 | $this->assertEquals('bar', $values[0]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Converter/ConverterManager.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ConverterManager implements ConverterInterface 11 | { 12 | private ContainerInterface $converters; 13 | 14 | public function __construct(ContainerInterface $converters) 15 | { 16 | $this->converters = $converters; 17 | } 18 | 19 | public function convert(mixed $value, string $type): mixed 20 | { 21 | if ('object' === $type) { 22 | throw new \InvalidArgumentException('Type "object" is too ambiguous, provide a concrete class type.'); 23 | } 24 | 25 | if ($this->converters->has($type)) { 26 | /** @var ConverterInterface $converter */ 27 | $converter = $this->converters->get($type); 28 | 29 | return $converter->convert($value, $type); 30 | } 31 | 32 | // fallback to object converter if available 33 | if (class_exists($type) && $this->converters->has('object')) { 34 | /** @var ConverterInterface $converter */ 35 | $converter = $this->converters->get('object'); 36 | 37 | return $converter->convert($value, $type); 38 | } 39 | 40 | throw new \InvalidArgumentException(sprintf('Unsupported type "%s".', $type)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Converter/BuiltinTypeSafeConverter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class BuiltinTypeSafeConverter implements ConverterInterface 11 | { 12 | public function convert(mixed $value, string $type): string|int|bool|float 13 | { 14 | try { 15 | return match ($type) { 16 | 'int' => $this->convertToInt($value), 17 | 'float' => $this->convertToFloat($value), 18 | 'bool' => $this->convertToBool($value), 19 | 'string' => $this->convertToString($value), 20 | default => throw new \InvalidArgumentException(sprintf('Unsupported type "%s".', $type)), 21 | }; 22 | } catch (\TypeError $e) { 23 | throw new TypeConversionException($e->getMessage(), 0, $e); 24 | } 25 | } 26 | 27 | private function convertToInt(int $value): int 28 | { 29 | return $value; 30 | } 31 | 32 | private function convertToFloat(float $value): float 33 | { 34 | return $value; 35 | } 36 | 37 | private function convertToBool(bool $value): bool 38 | { 39 | return $value; 40 | } 41 | 42 | private function convertToString(string $value): string 43 | { 44 | return $value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jungi/framework-extra-bundle", 3 | "description": "attributes for request and response operations, content negotiation, and more for symfony projects", 4 | "keywords": ["body", "param", "conversion", "mapper"], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "minimum-stability": "dev", 8 | "require": { 9 | "php": ">=8.0.2", 10 | "symfony/framework-bundle": "^6.0", 11 | "symfony/http-kernel": "^6.0", 12 | "symfony/dependency-injection": "^6.0" 13 | }, 14 | "suggest": { 15 | "symfony/serializer": "For serializer adapters" 16 | }, 17 | "require-dev": { 18 | "symfony/serializer": "^6.0", 19 | "symfony/property-info": "^6.0", 20 | "symfony/property-access": "^6.0", 21 | "phpunit/phpunit": "^9.5", 22 | "friendsofphp/php-cs-fixer": "^3.11" 23 | }, 24 | "authors": [ 25 | { 26 | "name": "Piotr Kugla", 27 | "email": "piku235@gmail.com" 28 | } 29 | ], 30 | "autoload": { 31 | "psr-4": { 32 | "Jungi\\FrameworkExtraBundle\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Jungi\\FrameworkExtraBundle\\Tests\\": "tests/" 38 | } 39 | }, 40 | "config": { 41 | "bin-dir": "bin" 42 | }, 43 | "extra": { 44 | "branch-alias": { 45 | "dev-main": "2.x-dev" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/RegisterConvertersPass.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @internal 16 | */ 17 | final class RegisterConvertersPass implements CompilerPassInterface 18 | { 19 | public function process(ContainerBuilder $container): void 20 | { 21 | $map = []; 22 | foreach ($container->findTaggedServiceIds('jungi.converter') as $id => $attributes) { 23 | foreach ($attributes as $attribute) { 24 | if (!isset($attribute['type'])) { 25 | throw new InvalidArgumentException(sprintf('Service "%s" must define the "type" attribute on "jungi.converter" tag.', $id)); 26 | } 27 | 28 | $map[$attribute['type']] = new Reference($id); 29 | } 30 | } 31 | 32 | $definition = $container->getDefinition(ConverterManager::class); 33 | $definition->setArgument(0, ServiceLocatorTagPass::register($container, $map)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/EventListener/EntityResponseListener.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @internal 15 | */ 16 | final class EntityResponseListener implements EventSubscriberInterface 17 | { 18 | private const DEFAULT_CONTENT_TYPE = 'application/json'; 19 | 20 | private MessageBodyMapperManager $messageBodyMapperManager; 21 | private string $defaultContentType; 22 | 23 | public function __construct(MessageBodyMapperManager $messageBodyMapperManager, string $defaultContentType = self::DEFAULT_CONTENT_TYPE) 24 | { 25 | $this->messageBodyMapperManager = $messageBodyMapperManager; 26 | $this->defaultContentType = $defaultContentType; 27 | } 28 | 29 | public function onKernelResponse(ResponseEvent $event): void 30 | { 31 | if (!$event->isMainRequest()) { 32 | return; 33 | } 34 | 35 | $response = $event->getResponse(); 36 | if ($response instanceof EntityResponse) { 37 | $response->negotiateContent($event->getRequest(), $this->messageBodyMapperManager, $this->defaultContentType); 38 | } 39 | } 40 | 41 | public static function getSubscribedEvents(): array 42 | { 43 | return [ 44 | KernelEvents::RESPONSE => 'onKernelResponse', 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/DependencyInjection/JungiFrameworkExtraExtension.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class JungiFrameworkExtraExtension extends Extension 17 | { 18 | public function load(array $configs, ContainerBuilder $container): void 19 | { 20 | $configuration = $this->getConfiguration($configs, $container); 21 | $config = $this->processConfiguration($configuration, $configs); 22 | 23 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../../config')); 24 | $loader->load('services.xml'); 25 | $loader->load('attributes.xml'); 26 | 27 | if ($config['serializer'] && interface_exists(SerializerInterface::class)) { 28 | $loader->load('serializer.xml'); 29 | } 30 | 31 | $entityResponseListener = $container->getDefinition(EntityResponseListener::class); 32 | $entityResponseListener->replaceArgument(1, $config['default_content_type']); 33 | 34 | $requestBodyValueResolver = $container->getDefinition(RequestBodyValueResolver::class); 35 | $requestBodyValueResolver->replaceArgument(2, $config['default_content_type']); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/serializer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | json 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | true 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/RequestHeaderValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class RequestHeaderValueResolverTest extends TestCase 16 | { 17 | /** 18 | * @test 19 | * @dataProvider provideArgumentValues 20 | */ 21 | public function argumentValueIsResolved(string $type, mixed $value) 22 | { 23 | $converter = $this->createMock(ConverterInterface::class); 24 | $converter 25 | ->expects($this->once()) 26 | ->method('convert') 27 | ->willReturnArgument(0); 28 | 29 | $resolver = new RequestHeaderValueResolver($converter); 30 | 31 | $request = new Request(); 32 | $request->headers->set('foo', $value); 33 | 34 | $argument = new ArgumentMetadata('foo', $type, false, false, null, false, [ 35 | new RequestHeader('foo') 36 | ]); 37 | 38 | $values = $resolver->resolve($request, $argument); 39 | 40 | $this->assertCount(1, $values); 41 | $this->assertSame($value, $values[0]); 42 | } 43 | 44 | public function provideArgumentValues(): iterable 45 | { 46 | yield ['string', 'bar']; 47 | yield ['array', ['multi', 'bar']]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/EventListener/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * @internal 17 | */ 18 | final class ExceptionListener implements EventSubscriberInterface 19 | { 20 | public function onKernelException(ExceptionEvent $event): void 21 | { 22 | $e = $event->getThrowable(); 23 | 24 | switch (true) { 25 | case $e instanceof NotAcceptableMediaTypeException: 26 | $event->setThrowable(new NotAcceptableHttpException(sprintf( 27 | 'Could not respond with any acceptable content types. Only following are supported: %s.', 28 | implode(', ', $e->getSupportedMediaTypes()) 29 | ), $e)); 30 | break; 31 | case $e instanceof UnsupportedMediaTypeException: 32 | $event->setThrowable(new UnsupportedMediaTypeHttpException(sprintf( 33 | 'Content type "%s" is not supported.', 34 | $e->getMediaType() 35 | ), $e)); 36 | break; 37 | } 38 | } 39 | 40 | public static function getSubscribedEvents(): array 41 | { 42 | return [ 43 | KernelEvents::EXCEPTION => 'onKernelException', 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Http/MessageBodyMapperManager.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @final 13 | */ 14 | class MessageBodyMapperManager 15 | { 16 | private ServiceProviderInterface $mappers; 17 | 18 | public function __construct(ServiceProviderInterface $mappers) 19 | { 20 | $this->mappers = $mappers; 21 | } 22 | 23 | /** 24 | * @return string[] 25 | */ 26 | public function getSupportedMediaTypes(): array 27 | { 28 | return array_keys($this->mappers->getProvidedServices()); 29 | } 30 | 31 | /** 32 | * @throws UnsupportedMediaTypeException 33 | * @throws MalformedDataException 34 | */ 35 | public function mapFrom(string $messageBody, string $mediaType, string $type): mixed 36 | { 37 | if (!$this->mappers->has($mediaType)) { 38 | throw UnsupportedMediaTypeException::mapperNotRegistered($mediaType); 39 | } 40 | 41 | /** @var MapperInterface $mapper */ 42 | $mapper = $this->mappers->get($mediaType); 43 | 44 | return $mapper->mapFrom($messageBody, $type); 45 | } 46 | 47 | /** 48 | * @throws UnsupportedMediaTypeException 49 | */ 50 | public function mapTo(mixed $data, string $mediaType): string 51 | { 52 | if (!$this->mappers->has($mediaType)) { 53 | throw UnsupportedMediaTypeException::mapperNotRegistered($mediaType); 54 | } 55 | 56 | /** @var MapperInterface $mapper */ 57 | $mapper = $this->mappers->get($mediaType); 58 | 59 | return $mapper->mapTo($data); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/EventListener/ExceptionListenerTest.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class ExceptionListenerTest extends TestCase 19 | { 20 | /** 21 | * @test 22 | * @dataProvider provideExceptions 23 | */ 24 | public function exceptionIsReplaced(string $expectedExceptionClass, \Exception $thrown) 25 | { 26 | $listener = new ExceptionListener(); 27 | 28 | $event = new ExceptionEvent( 29 | $this->createMock(HttpKernelInterface::class), 30 | new Request(), 31 | HttpKernelInterface::MAIN_REQUEST, 32 | $thrown 33 | ); 34 | $listener->onKernelException($event); 35 | 36 | $this->assertInstanceOf($expectedExceptionClass, $event->getThrowable()); 37 | } 38 | 39 | public function provideExceptions() 40 | { 41 | yield [ 42 | UnsupportedMediaTypeHttpException::class, 43 | new UnsupportedMediaTypeException('application/xml', 'Unsupported media type.'), 44 | ]; 45 | yield [ 46 | NotAcceptableHttpException::class, 47 | new NotAcceptableMediaTypeException(['application/xml'], ['application/json'], 'Not acceptable media type'), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/RegisterMessageBodyMappersPass.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * @internal 17 | */ 18 | final class RegisterMessageBodyMappersPass implements CompilerPassInterface 19 | { 20 | public function process(ContainerBuilder $container): void 21 | { 22 | $map = []; 23 | foreach ($container->findTaggedServiceIds('jungi.message_body_mapper') as $id => $attributes) { 24 | foreach ($attributes as $attribute) { 25 | if (!isset($attribute['media_type'])) { 26 | throw new InvalidArgumentException(sprintf('Service "%s" must define the "media_type" attribute on "jungi.message_conversion_mapper" tag.', $id)); 27 | } 28 | if (true !== MediaTypeDescriptor::parseOrNull($attribute['media_type'])?->isSpecific()) { 29 | throw new InvalidArgumentException(sprintf('Service "%s" has the invalid media type "%s", it should be specific eg. "application/json".', $id, $attribute['media_type'])); 30 | } 31 | 32 | $map[$attribute['media_type']] = new Reference($id); 33 | } 34 | } 35 | 36 | $definition = $container->getDefinition(MessageBodyMapperManager::class); 37 | $definition->setArgument(0, ServiceLocatorTagPass::register($container, $map)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/QueryParamsValueResolver.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class QueryParamsValueResolver implements ArgumentValueResolverInterface 14 | { 15 | private ConverterInterface $converter; 16 | 17 | public function __construct(ConverterInterface $converter) 18 | { 19 | $this->converter = $converter; 20 | } 21 | 22 | public function supports(Request $request, ArgumentMetadata $argument): bool 23 | { 24 | return (bool) $argument->getAttributes(QueryParams::class, ArgumentMetadata::IS_INSTANCEOF); 25 | } 26 | 27 | public function resolve(Request $request, ArgumentMetadata $argument): array 28 | { 29 | if (!$argument->getAttributes(QueryParams::class, ArgumentMetadata::IS_INSTANCEOF)) { 30 | return []; 31 | } 32 | 33 | if (!$argument->getType()) { 34 | throw new \InvalidArgumentException(sprintf('Argument "%s" must have the type specified for the request query conversion.', $argument->getName())); 35 | } 36 | if ($argument->isNullable()) { 37 | throw new \InvalidArgumentException(sprintf('Argument "%s" cannot be nullable for the request query conversion.', $argument->getName())); 38 | } 39 | if (!class_exists($argument->getType())) { 40 | throw new \InvalidArgumentException(sprintf('Argument "%s" must be of concrete class type for the request query conversion.', $argument->getName())); 41 | } 42 | 43 | return [$this->converter->convert($request->query->all(), $argument->getType())]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Filesystem/TmpFileUtilsTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class TmpFileUtilsTest extends TestCase 12 | { 13 | /** @test */ 14 | public function data() 15 | { 16 | $content = 'hello,world,123,1.23'; 17 | 18 | $file = TmpFileUtils::fromData($content); 19 | $handle = fopen($file, 'rb'); 20 | 21 | $this->assertEquals($content, stream_get_contents($handle)); 22 | 23 | // clear tmpfile references to remove it from the filesystem 24 | TmpFileUtils::removeReferences(); 25 | 26 | $this->assertFileDoesNotExist($file); 27 | } 28 | 29 | /** @test */ 30 | public function rename() 31 | { 32 | $content = 'hello-world'; 33 | 34 | $file = TmpFileUtils::fromData($content); 35 | $handle = fopen($file, 'rb'); 36 | 37 | $movedFile = tempnam(sys_get_temp_dir(), 'foo'); 38 | rename($file, $movedFile); 39 | 40 | $this->assertFileDoesNotExist($file); 41 | 42 | TmpFileUtils::removeReferences(); 43 | 44 | $this->assertFileExists($movedFile); 45 | $this->assertEquals($content, stream_get_contents($handle)); 46 | 47 | unlink($movedFile); 48 | } 49 | 50 | /** @test */ 51 | public function copy() 52 | { 53 | $content = 'hello-world'; 54 | 55 | $file = TmpFileUtils::fromData($content); 56 | $handle = fopen($file, 'rb'); 57 | 58 | $copiedFile = tempnam(sys_get_temp_dir(), 'foo'); 59 | copy($file, $copiedFile); 60 | 61 | $this->assertFileExists($file); 62 | 63 | TmpFileUtils::removeReferences(); 64 | 65 | $this->assertFileExists($copiedFile); 66 | $this->assertEquals($content, stream_get_contents($handle)); 67 | 68 | unlink($copiedFile); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Serializer/ObjectAlreadyOfTypeDenormalizerTest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ObjectAlreadyOfTypeDenormalizerTest extends TestCase 13 | { 14 | /** @test */ 15 | public function supports(): void 16 | { 17 | $denormalizer = new ObjectAlreadyOfTypeDenormalizer(); 18 | 19 | $this->assertTrue($denormalizer->supportsDenormalization(new A(), A::class)); 20 | $this->assertFalse($denormalizer->supportsDenormalization(new B(), A::class)); 21 | $this->assertFalse($denormalizer->supportsDenormalization('foo', 'string')); 22 | } 23 | 24 | /** @test */ 25 | public function denormalize(): void 26 | { 27 | $denormalizer = new ObjectAlreadyOfTypeDenormalizer(); 28 | $expected = new A(); 29 | 30 | $this->assertSame($expected, $denormalizer->denormalize($expected, A::class)); 31 | } 32 | 33 | /** @test */ 34 | public function denormalizeOnNonObjectTypeFails(): void 35 | { 36 | $this->expectException(InvalidArgumentException::class); 37 | $this->expectExceptionMessage('Data must be of object type, given "string".'); 38 | 39 | $denormalizer = new ObjectAlreadyOfTypeDenormalizer(); 40 | $denormalizer->denormalize('foo', 'string'); 41 | } 42 | 43 | /** @test */ 44 | public function denormalizeOnDifferentClassTypeFails(): void 45 | { 46 | $this->expectException(InvalidArgumentException::class); 47 | $this->expectExceptionMessage(sprintf('Data expected to be of type "%s", given "%s".', A::class, B::class)); 48 | 49 | $denormalizer = new ObjectAlreadyOfTypeDenormalizer(); 50 | $denormalizer->denormalize(new B(), A::class); 51 | } 52 | } 53 | 54 | class A { 55 | 56 | } 57 | 58 | class B extends A { 59 | 60 | } -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/RequestParamValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class RequestParamValueResolverTest extends TestCase 17 | { 18 | /** @test */ 19 | public function argumentValueIsResolved() 20 | { 21 | $converter = $this->createMock(ConverterInterface::class); 22 | $resolver = new RequestParamValueResolver($converter); 23 | 24 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 25 | new RequestParam() 26 | ]); 27 | $request = new Request([], [ 28 | 'foo' => 'bar' 29 | ]); 30 | 31 | $values = $resolver->resolve($request, $argument); 32 | 33 | $this->assertCount(1, $values); 34 | $this->assertEquals('bar', $values[0]); 35 | } 36 | 37 | /** @test */ 38 | public function argumentValueIsResolvedToUploadedFile() 39 | { 40 | $converter = $this->createMock(ConverterInterface::class); 41 | $resolver = new RequestParamValueResolver($converter); 42 | 43 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 44 | new RequestParam() 45 | ]); 46 | $expected = new UploadedFile(__DIR__.'/../../Fixtures/uploaded_file', 'uploaded_file', 'text/plain'); 47 | $request = new Request([], [], [], [], [ 48 | 'foo' => $expected 49 | ]); 50 | 51 | $values = $resolver->resolve($request, $argument); 52 | 53 | $this->assertCount(1, $values); 54 | $this->assertSame($expected, $values[0]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Http/ContentDispositionDescriptor.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | final class ContentDispositionDescriptor 13 | { 14 | private const TYPE_INLINE = 'inline'; 15 | 16 | private string $type; 17 | private array $params; 18 | 19 | public static function parse(string $contentDisposition): self 20 | { 21 | if (!$contentDisposition) { 22 | throw new \InvalidArgumentException('Content disposition cannot be empty.'); 23 | } 24 | 25 | $parts = HeaderUtils::split($contentDisposition, ';='); 26 | 27 | $type = array_shift($parts)[0]; 28 | if (!$type) { 29 | throw new \InvalidArgumentException('Type is not defined.'); 30 | } 31 | 32 | $params = []; 33 | foreach ($parts as $part) { 34 | if (!isset($part[0])) { 35 | throw new \InvalidArgumentException('Encountered on an invalid parameter.'); 36 | } 37 | if (!isset($part[1])) { 38 | throw new \InvalidArgumentException(sprintf('Value is missing for the parameter "%s".', $part[0])); 39 | } 40 | 41 | $params[$part[0]] = $part[1]; 42 | } 43 | 44 | return new self($type, $params); 45 | } 46 | 47 | public function __construct(string $type, array $params) 48 | { 49 | $this->type = $type; 50 | $this->params = $params; 51 | } 52 | 53 | public function isInline(): bool 54 | { 55 | return self::TYPE_INLINE === $this->type; 56 | } 57 | 58 | public function getType(): string 59 | { 60 | return $this->type; 61 | } 62 | 63 | public function getFilename(): ?string 64 | { 65 | return $this->params['filename'] ?? null; 66 | } 67 | 68 | public function hasParam(string $name): bool 69 | { 70 | return isset($this->params[$name]); 71 | } 72 | 73 | public function getParam(string $name): ?string 74 | { 75 | return $this->params[$name] ?? null; 76 | } 77 | 78 | public function getParams(): array 79 | { 80 | return $this->params; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/EventListener/EntityResponseListenerTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class EntityResponseListenerTest extends TestCase 17 | { 18 | private HttpKernelInterface $kernel; 19 | private MessageBodyMapperManager $messageBodyMapperManager; 20 | 21 | protected function setUp(): void 22 | { 23 | $this->kernel = $this->createMock(HttpKernelInterface::class); 24 | $this->messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 25 | } 26 | 27 | /** @test */ 28 | public function contentIsNegotiated() 29 | { 30 | $response = $this->createMock(EntityResponse::class); 31 | $response 32 | ->expects($this->once()) 33 | ->method('negotiateContent') 34 | ->with( 35 | $this->isInstanceOf(Request::class), 36 | $this->isInstanceOf(MessageBodyMapperManager::class), 37 | 'application/xml' 38 | ); 39 | 40 | $listener = new EntityResponseListener($this->messageBodyMapperManager, 'application/xml'); 41 | 42 | $event = new ResponseEvent($this->kernel, new Request(), HttpKernelInterface::MAIN_REQUEST, $response); 43 | $listener->onKernelResponse($event); 44 | } 45 | 46 | /** @test */ 47 | public function contentIsNotNegotiatedForSubRequests() 48 | { 49 | $response = $this->createMock(EntityResponse::class); 50 | $response 51 | ->expects($this->never()) 52 | ->method('negotiateContent'); 53 | 54 | $listener = new EntityResponseListener($this->messageBodyMapperManager); 55 | 56 | $event = new ResponseEvent($this->kernel, new Request(), HttpKernelInterface::SUB_REQUEST, $response); 57 | $listener->onKernelResponse($event); 58 | } 59 | } -------------------------------------------------------------------------------- /tests/Converter/BuiltinTypeSafeConverterTest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class BuiltinTypeSafeConverterTest extends TestCase 13 | { 14 | /** 15 | * @test 16 | * @dataProvider provideValid 17 | */ 18 | public function valid($expectedValue, $value, $type) 19 | { 20 | $converter = new BuiltinTypeSafeConverter(); 21 | $actualValue = $converter->convert($value, $type); 22 | 23 | $this->assertSame($expectedValue, $actualValue); 24 | } 25 | 26 | /** 27 | * @test 28 | * @dataProvider provideInvalid 29 | */ 30 | public function invalid($value, $type) 31 | { 32 | $this->expectException(TypeConversionException::class); 33 | 34 | $converter = new BuiltinTypeSafeConverter(); 35 | $converter->convert($value, $type); 36 | } 37 | 38 | /** 39 | * @test 40 | * @dataProvider provideUnsupported 41 | */ 42 | public function unsupported($type) 43 | { 44 | $this->expectException(\InvalidArgumentException::class); 45 | $this->expectExceptionMessage(sprintf('Unsupported type "%s"', $type)); 46 | 47 | $converter = new BuiltinTypeSafeConverter(); 48 | $converter->convert(123, $type); 49 | } 50 | 51 | /** @test */ 52 | public function nonNumericValuePhpNotice() 53 | { 54 | $converter = new BuiltinTypeSafeConverter(); 55 | 56 | try { 57 | $converter->convert('123foo', 'int'); 58 | } catch (TypeConversionException $e) { 59 | // continue 60 | } 61 | 62 | $this->assertEquals(123, $converter->convert('123', 'int')); 63 | } 64 | 65 | public function provideValid() 66 | { 67 | yield [123, '123', 'int']; 68 | yield ['1.23', 1.23, 'string']; 69 | yield [1.23, '1.23', 'float']; 70 | yield [true, 1, 'bool']; 71 | yield [false, 0, 'bool']; 72 | } 73 | 74 | public function provideInvalid() 75 | { 76 | yield ['foo', 'int']; 77 | yield ['foo', 'float']; 78 | yield ['123foo', 'int']; 79 | yield ['foo123', 'int']; 80 | yield ['foo1.23', 'float']; 81 | yield ['1.23foo', 'float']; 82 | } 83 | 84 | public function provideUnsupported() 85 | { 86 | yield ['object']; 87 | yield ['array']; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Converter/ConverterManagerTest.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ConverterManagerTest extends TestCase 14 | { 15 | /** @test */ 16 | public function converterForRegisteredType() 17 | { 18 | $manager = new ConverterManager(new ServiceLocator(array( 19 | 'int' => function () { 20 | $converter = $this->createMock(ConverterInterface::class); 21 | $converter 22 | ->expects($this->once()) 23 | ->method('convert') 24 | ->with('123', 'int'); 25 | 26 | return $converter; 27 | }, 28 | ))); 29 | 30 | $manager->convert('123', 'int'); 31 | } 32 | 33 | /** @test */ 34 | public function converterForNonRegisteredType() 35 | { 36 | $this->expectException(\InvalidArgumentException::class); 37 | 38 | $manager = new ConverterManager(new ServiceLocator([])); 39 | $manager->convert('123', 'int'); 40 | } 41 | 42 | /** @test */ 43 | public function converterForNonRegisteredObjectType() 44 | { 45 | $this->expectException(\InvalidArgumentException::class); 46 | 47 | $manager = new ConverterManager(new ServiceLocator([])); 48 | $manager->convert('1992-12-10 23:23:23', \DateTimeImmutable::class); 49 | } 50 | 51 | /** @test */ 52 | public function conversionToBuiltInObjectTypeIsNotSupported() 53 | { 54 | $this->expectException(\InvalidArgumentException::class); 55 | 56 | $manager = new ConverterManager(new ServiceLocator([])); 57 | $manager->convert('123', 'object'); 58 | } 59 | 60 | /** @test */ 61 | public function objectConverterIsUsedForNonRegisteredObjectType() 62 | { 63 | $manager = new ConverterManager(new ServiceLocator(array( 64 | 'object' => function () { 65 | $converter = $this->createMock(ConverterInterface::class); 66 | $converter 67 | ->expects($this->once()) 68 | ->method('convert') 69 | ->with('1992-12-10 23:23:23', \DateTime::class); 70 | 71 | return $converter; 72 | }, 73 | ))); 74 | $manager->convert('1992-12-10 23:23:23', \DateTime::class); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /config/attributes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/AbstractNamedValueArgumentValueResolver.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @internal 16 | */ 17 | abstract class AbstractNamedValueArgumentValueResolver implements ArgumentValueResolverInterface 18 | { 19 | protected static string $attributeClass; 20 | 21 | private ConverterInterface $converter; 22 | 23 | public function __construct(ConverterInterface $converter) 24 | { 25 | $this->converter = $converter; 26 | } 27 | 28 | public function supports(Request $request, ArgumentMetadata $argument): bool 29 | { 30 | return (bool) $argument->getAttributes(static::$attributeClass, ArgumentMetadata::IS_INSTANCEOF); 31 | } 32 | 33 | public function resolve(Request $request, ArgumentMetadata $argument): array 34 | { 35 | /** @var NamedValue|null $attribute */ 36 | $attribute = $argument->getAttributes(static::$attributeClass, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; 37 | 38 | if (null === $attribute) { 39 | return []; 40 | } 41 | 42 | if ($argument->isVariadic()) { 43 | throw new \InvalidArgumentException('Variadic arguments are not supported.'); 44 | } 45 | 46 | $namedValueArgument = new NamedValueArgument( 47 | $attribute->name() ?: $argument->getName(), 48 | $argument->getType(), 49 | $attribute 50 | ); 51 | $value = $this->getArgumentValue($namedValueArgument, $request); 52 | 53 | if (null === $value && $argument->hasDefaultValue()) { 54 | $value = $argument->getDefaultValue(); 55 | } 56 | 57 | if (null === $value) { 58 | if ($argument->isNullable()) { 59 | return [null]; 60 | } 61 | 62 | throw new BadRequestHttpException(sprintf('Argument "%s" cannot be found in the request.', $namedValueArgument->getName())); 63 | } 64 | 65 | if (null === $argument->getType()) { 66 | return [$value]; 67 | } 68 | 69 | try { 70 | return [$this->converter->convert($value, $argument->getType())]; 71 | } catch (TypeConversionException $e) { 72 | throw new BadRequestHttpException(sprintf('Cannot convert named argument "%s".', $namedValueArgument->getName()), $e); 73 | } 74 | } 75 | 76 | abstract protected function getArgumentValue(NamedValueArgument $argument, Request $request): mixed; 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JungiFrameworkExtraBundle 2 | 3 | [![Build Status](https://github.com/jungi-php/framework-extra-bundle/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/jungi-php/framework-extra-bundle/actions) 4 | ![PHP](https://img.shields.io/packagist/php-v/jungi/framework-extra-bundle) 5 | 6 | This bundle adds additional features whose main purpose is to facilitate request/response operations. 7 | 8 | Attributes: 9 | 10 | * [`RequestBody`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#requestbody) 11 | * [`RequestHeader`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#requestheader) 12 | * [`RequestCookie`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#requestcookie) 13 | * [`RequestParam`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#requestparam) 14 | * [`QueryParam`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#queryparam) 15 | * [`QueryParams`](https://piku235.gitbook.io/jungiframeworkextrabundle/attributes#queryparams) 16 | 17 | ## Development 18 | 19 | With the new release of Symfony v6.3 [mapping request data to typed objects](https://symfony.com/blog/new-in-symfony-6-3-mapping-request-data-to-typed-objects), the development and further need for this bundle has come to an end. 20 | 21 | ## Documentation 22 | 23 | [GitBook](https://piku235.gitbook.io/jungiframeworkextrabundle) 24 | 25 | ## Quick insight 26 | 27 | ```php 28 | namespace App\Controller; 29 | 30 | use Symfony\Component\HttpFoundation\File\UploadedFile; 31 | use Symfony\Component\Routing\Attribute\Route; 32 | use Jungi\FrameworkExtraBundle\Attribute\QueryParams; 33 | use Jungi\FrameworkExtraBundle\Attribute\RequestBody; 34 | use Jungi\FrameworkExtraBundle\Attribute\QueryParam; 35 | use Jungi\FrameworkExtraBundle\Attribute\RequestParam; 36 | use Jungi\FrameworkExtraBundle\Controller\ControllerTrait; 37 | 38 | #[Route('/users')] 39 | class UserController 40 | { 41 | use ControllerTrait; 42 | 43 | #[Route('/{userId}/residential-address', methods: ['PATCH'])] 44 | public function changeResidentialAddress(string $userId, #[RequestBody] UserResidentialAddressData $data) 45 | { 46 | // .. 47 | } 48 | 49 | #[Route('/{userId}/files/{fileName}', methods: ['PUT'])] 50 | public function uploadFile(string $userId, string $fileName, #[RequestBody] UploadedFile $file) 51 | { 52 | // .. 53 | } 54 | 55 | #[Route('/{userId}/avatar', methods: ['PATCH'])] 56 | public function replaceAvatar(string $userId, #[RequestParam] UploadedFile $file, #[RequestParam] string $title) 57 | { 58 | // .. 59 | } 60 | 61 | #[Route(methods: ['GET'])] 62 | public function getUsers(#[QueryParam] ?int $limit = null, #[QueryParam] ?int $offset = null) 63 | { 64 | // .. 65 | } 66 | 67 | #[Route(methods: ['GET'])] 68 | public function filterUsers(#[QueryParams] FilterUsersDto $filterData) 69 | { 70 | // .. 71 | /** @var UserData[] $filteredUsers */ 72 | return $this->entity($filteredUsers); 73 | } 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /tests/Http/ContentDispositionDescriptorTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ContentDispositionDescriptorTest extends TestCase 12 | { 13 | /** @test */ 14 | public function parse() 15 | { 16 | $contentDisposition = ContentDispositionDescriptor::parse('inline; filename = foo123.csv; foo = "long value"'); 17 | 18 | $this->assertTrue($contentDisposition->isInline()); 19 | $this->assertEquals('foo123.csv', $contentDisposition->getFilename()); 20 | $this->assertEquals('long value', $contentDisposition->getParam('foo')); 21 | } 22 | 23 | /** @test */ 24 | public function parseMinimal() 25 | { 26 | $contentDisposition = ContentDispositionDescriptor::parse('custom'); 27 | 28 | $this->assertEquals('custom', $contentDisposition->getType()); 29 | } 30 | 31 | /** @test */ 32 | public function parseWithBlankParams() 33 | { 34 | $contentDisposition = ContentDispositionDescriptor::parse('inline;;'); 35 | 36 | $this->assertEquals('inline', $contentDisposition->getType()); 37 | $this->assertEmpty($contentDisposition->getParams()); 38 | } 39 | 40 | /** 41 | * @test 42 | * @dataProvider provideInvalidContentDispositions 43 | */ 44 | public function parseInvalid($contentDisposition) 45 | { 46 | $this->expectException(\InvalidArgumentException::class); 47 | 48 | ContentDispositionDescriptor::parse($contentDisposition); 49 | } 50 | 51 | /** @test */ 52 | public function create() 53 | { 54 | $params = array( 55 | 'filename' => 'foo123.csv', 56 | 'foo' => 'bar', 57 | ); 58 | $contentDisposition = new ContentDispositionDescriptor('inline', $params); 59 | 60 | $this->assertTrue($contentDisposition->isInline()); 61 | $this->assertEquals('inline', $contentDisposition->getType()); 62 | $this->assertEquals($params, $contentDisposition->getParams()); 63 | $this->assertTrue($contentDisposition->hasParam('foo')); 64 | $this->assertEquals('bar', $contentDisposition->getParam('foo')); 65 | $this->assertEquals('foo123.csv', $contentDisposition->getFilename()); 66 | $this->assertFalse($contentDisposition->hasParam('invalid')); 67 | $this->assertNull($contentDisposition->getParam('invalid')); 68 | 69 | $params = array( 70 | 'foo' => 'bar', 71 | 'hello' => 'world' 72 | ); 73 | $contentDisposition = new ContentDispositionDescriptor('attachment', $params); 74 | 75 | $this->assertFalse($contentDisposition->isInline()); 76 | $this->assertNull($contentDisposition->getFilename()); 77 | $this->assertEquals('attachment', $contentDisposition->getType()); 78 | } 79 | 80 | public function provideInvalidContentDispositions() 81 | { 82 | yield ['']; 83 | yield ['inline; filename = foo123.csv; foo']; 84 | yield ['inline; filename = foo123.csv; =']; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Mapper/SerializerMapperAdapter.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class SerializerMapperAdapter implements MapperInterface 19 | { 20 | private string $format; 21 | private array $context; 22 | 23 | private SerializerInterface|DenormalizerInterface|NormalizerInterface $serializer; 24 | 25 | public function __construct(string $format, SerializerInterface $serializer, array $context = []) 26 | { 27 | $this->format = $format; 28 | $this->context = $context; 29 | $this->setSerializer($serializer); 30 | } 31 | 32 | public function mapFrom(string $data, string $type): mixed 33 | { 34 | try { 35 | return $this->serializer->deserialize($data, $type, $this->format, $this->context); 36 | } catch (InvalidArgumentException | UnexpectedValueException | MissingConstructorArgumentsException | \TypeError $e) { 37 | if ($e instanceof NotNormalizableValueException 38 | && !$this->serializer->supportsDenormalization($data, $type, $this->format) 39 | ) { 40 | throw new \InvalidArgumentException(sprintf('Cannot deserialize data to type "%s".', $type), 0, $e); 41 | } 42 | 43 | throw new MalformedDataException(sprintf('Data is malformed: %s', $e->getMessage()), 0, $e); 44 | } 45 | } 46 | 47 | public function mapTo(mixed $data): string 48 | { 49 | try { 50 | return $this->serializer->serialize($data, $this->format, $this->context); 51 | } catch (NotNormalizableValueException $e) { 52 | throw new \InvalidArgumentException(sprintf('Cannot serialize data to format "%s".', $this->format), 0, $e); 53 | } 54 | } 55 | 56 | private function setSerializer(SerializerInterface $serializer): void 57 | { 58 | if (!$serializer instanceof NormalizerInterface || !$serializer instanceof DenormalizerInterface 59 | || !$serializer instanceof EncoderInterface || !$serializer instanceof DecoderInterface 60 | ) { 61 | throw new \InvalidArgumentException('Expected a serializer that also implements NormalizerInterface, DenormalizerInterface, EncoderInterface and DecoderInterface.'); 62 | } 63 | 64 | if (!$serializer->supportsEncoding($this->format) || !$serializer->supportsDecoding($this->format)) { 65 | throw new \InvalidArgumentException(sprintf('Format "%s" is not fully supported by serializer.', $this->format)); 66 | } 67 | 68 | $this->serializer = $serializer; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Http/MediaTypeDescriptor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * @internal 9 | */ 10 | final class MediaTypeDescriptor 11 | { 12 | private const WILDCARD = '*'; 13 | private const SEPARATOR = '/'; 14 | 15 | private string $type; 16 | private string $subType; 17 | 18 | /** 19 | * @param string[] $mediaTypes 20 | * 21 | * @return self[] 22 | */ 23 | public static function parseList(array $mediaTypes): array 24 | { 25 | return array_map(fn (string $mediaType) => self::parse($mediaType), $mediaTypes); 26 | } 27 | 28 | public static function parse(string $mediaType): self 29 | { 30 | if (1 !== substr_count($mediaType, self::SEPARATOR)) { 31 | throw new \InvalidArgumentException(sprintf('Invalid media type "%s".', $mediaType)); 32 | } 33 | 34 | $parts = explode(self::SEPARATOR, $mediaType); 35 | 36 | return new self($parts[0], $parts[1]); 37 | } 38 | 39 | public static function parseOrNull(string $mediaType): ?self 40 | { 41 | try { 42 | return self::parse($mediaType); 43 | } catch (\InvalidArgumentException $e) { 44 | return null; 45 | } 46 | } 47 | 48 | public static function listToString(array $mediaTypes): array 49 | { 50 | return array_map(fn (self $descriptor) => (string) $descriptor, $mediaTypes); 51 | } 52 | 53 | public function __construct(string $type, string $subType) 54 | { 55 | if (!$type) { 56 | throw new \InvalidArgumentException('Type cannot be empty.'); 57 | } 58 | if (!$subType) { 59 | throw new \InvalidArgumentException('Subtype cannot be empty.'); 60 | } 61 | if (self::WILDCARD === $type && self::WILDCARD !== $subType) { 62 | throw new \InvalidArgumentException(sprintf('Invalid media type syntax "%s/%s".', self::WILDCARD, $subType)); 63 | } 64 | 65 | $this->type = $type; 66 | $this->subType = $subType; 67 | } 68 | 69 | public function inRange(self $mediaType): bool 70 | { 71 | return (self::WILDCARD === $this->type && self::WILDCARD === $this->subType) 72 | || (self::WILDCARD === $mediaType->type && self::WILDCARD === $mediaType->subType) 73 | || ($this->type === $mediaType->type && self::WILDCARD === $this->subType) 74 | || ($this->type === $mediaType->type && $this->subType === $mediaType->subType); 75 | } 76 | 77 | public function isSpecific(): bool 78 | { 79 | return self::WILDCARD !== $this->type && self::WILDCARD !== $this->subType; 80 | } 81 | 82 | public function isRange(): bool 83 | { 84 | return self::WILDCARD === $this->type || self::WILDCARD === $this->subType; 85 | } 86 | 87 | public function getType(): string 88 | { 89 | return $this->type; 90 | } 91 | 92 | public function getSubType(): string 93 | { 94 | return $this->subType; 95 | } 96 | 97 | public function __toString(): string 98 | { 99 | return $this->type.self::SEPARATOR.$this->subType; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/Http/MediaTypeDescriptorTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class MediaTypeDescriptorTest extends TestCase 12 | { 13 | /** @test */ 14 | public function parse() 15 | { 16 | $descriptor = MediaTypeDescriptor::parse('application/json'); 17 | 18 | $this->assertEquals('application', $descriptor->getType()); 19 | $this->assertEquals('json', $descriptor->getSubType()); 20 | } 21 | 22 | /** @test */ 23 | public function parseOrNull() 24 | { 25 | $descriptor = MediaTypeDescriptor::parseOrNull('application/json'); 26 | 27 | $this->assertEquals('application', $descriptor->getType()); 28 | $this->assertEquals('json', $descriptor->getSubType()); 29 | 30 | $this->assertNull(MediaTypeDescriptor::parseOrNull('invalid')); 31 | } 32 | 33 | /** 34 | * @test 35 | * @dataProvider provideInvalidInputs 36 | */ 37 | public function parseInvalidInput(string $mediaType) 38 | { 39 | $this->expectException(\InvalidArgumentException::class); 40 | 41 | MediaTypeDescriptor::parse($mediaType); 42 | } 43 | 44 | /** @test */ 45 | public function parseList() 46 | { 47 | list($json, $xml) = MediaTypeDescriptor::parseList(['application/json', 'text/xml']); 48 | 49 | $this->assertEquals('application', $json->getType()); 50 | $this->assertEquals('json', $json->getSubType()); 51 | 52 | $this->assertEquals('text', $xml->getType()); 53 | $this->assertEquals('xml', $xml->getSubType()); 54 | } 55 | 56 | /** @test */ 57 | public function create() 58 | { 59 | $descriptor = new MediaTypeDescriptor('application', 'xml'); 60 | 61 | $this->assertEquals('application', $descriptor->getType()); 62 | $this->assertEquals('xml', $descriptor->getSubType()); 63 | $this->assertEquals('application/xml', (string) $descriptor); 64 | $this->assertTrue($descriptor->isSpecific()); 65 | $this->assertFalse($descriptor->isRange()); 66 | 67 | $descriptor = new MediaTypeDescriptor('application', '*'); 68 | 69 | $this->assertEquals('application', $descriptor->getType()); 70 | $this->assertEquals('*', $descriptor->getSubType()); 71 | $this->assertEquals('application/*', (string) $descriptor); 72 | $this->assertFalse($descriptor->isSpecific()); 73 | $this->assertTrue($descriptor->isRange()); 74 | 75 | $descriptor = new MediaTypeDescriptor('*', '*'); 76 | 77 | $this->assertEquals('*', $descriptor->getType()); 78 | $this->assertEquals('*', $descriptor->getSubType()); 79 | $this->assertEquals('*/*', (string) $descriptor); 80 | $this->assertFalse($descriptor->isSpecific()); 81 | $this->assertTrue($descriptor->isRange()); 82 | } 83 | 84 | /** @test */ 85 | public function createWithMisplacedWildcard() 86 | { 87 | $this->expectException(\InvalidArgumentException::class); 88 | 89 | new MediaTypeDescriptor('*', 'xml'); 90 | } 91 | 92 | /** 93 | * @test 94 | * @dataProvider provideMediaTypesInRange 95 | */ 96 | public function isMediaTypeInRangeOf($mediaType, $checkedMediaType) 97 | { 98 | $descriptor = MediaTypeDescriptor::parse($mediaType); 99 | $checkedDescriptor = MediaTypeDescriptor::parse($checkedMediaType); 100 | 101 | $this->assertTrue($descriptor->inRange($checkedDescriptor)); 102 | } 103 | 104 | /** 105 | * @test 106 | * @dataProvider provideMediaTypesNotInRange 107 | */ 108 | public function isMediaTypeNotInRangeOf($mediaType, $checkedMediaType) 109 | { 110 | $descriptor = MediaTypeDescriptor::parse($mediaType); 111 | $checkedDescriptor = MediaTypeDescriptor::parse($checkedMediaType); 112 | 113 | $this->assertFalse($descriptor->inRange($checkedDescriptor)); 114 | } 115 | 116 | public function provideMediaTypesInRange() 117 | { 118 | yield ['application/xml', 'application/xml']; 119 | yield ['text/*', 'text/json']; 120 | yield ['*/*', 'image/png']; 121 | yield ['application/*', 'application/*']; 122 | yield ['*/*', '*/*']; 123 | } 124 | 125 | public function provideMediaTypesNotInRange() 126 | { 127 | yield ['application/xml', 'application/json']; 128 | yield ['text/*', 'application/*']; 129 | } 130 | 131 | public function provideInvalidInputs() 132 | { 133 | yield ['application/json/more']; 134 | yield ['application/']; 135 | yield ['text']; 136 | yield ['/']; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/QueryParamsValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class QueryParamsValueResolverTest extends TestCase 17 | { 18 | /** @test */ 19 | public function supports() 20 | { 21 | $resolver = new QueryParamsValueResolver($this->createMock(ConverterInterface::class)); 22 | $request = new Request(); 23 | 24 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 25 | new QueryParams() 26 | ]); 27 | $this->assertTrue($resolver->supports($request, $argument)); 28 | 29 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 30 | new ForeignAttribute() 31 | ]); 32 | $this->assertFalse($resolver->supports($request, $argument)); 33 | 34 | $argument = new ArgumentMetadata('bar', null, false, false, null); 35 | $this->assertFalse($resolver->supports($request, $argument)); 36 | } 37 | 38 | /** @test */ 39 | public function parameterIsConverted() 40 | { 41 | $request = new Request(['foo' => 'bar']); 42 | 43 | $converter = $this->createMock(ConverterInterface::class); 44 | $converter 45 | ->expects($this->once()) 46 | ->method('convert') 47 | ->with($request->query->all(), 'stdClass') 48 | ->willReturn(new \stdClass()); 49 | 50 | $resolver = new QueryParamsValueResolver($converter); 51 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 52 | new QueryParams() 53 | ]); 54 | 55 | $values = $resolver->resolve($request, $argument); 56 | 57 | $this->assertCount(1, $values); 58 | $this->assertInstanceOf('stdClass', $values[0]); 59 | } 60 | 61 | /** @test */ 62 | public function resolveForArgumentWithoutAttributeIsIgnored() 63 | { 64 | $converter = $this->createMock(ConverterInterface::class); 65 | $converter 66 | ->expects($this->never()) 67 | ->method('convert'); 68 | 69 | $resolver = new QueryParamsValueResolver($converter); 70 | $request = new Request(); 71 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false); 72 | 73 | $this->assertEmpty($resolver->resolve($request, $argument)); 74 | } 75 | 76 | /** @test */ 77 | public function resolveForNullableArgumentFails() 78 | { 79 | $this->expectException(\InvalidArgumentException::class); 80 | $this->expectExceptionMessage('Argument "foo" cannot be nullable'); 81 | 82 | $converter = $this->createMock(ConverterInterface::class); 83 | $resolver = new QueryParamsValueResolver($converter); 84 | $request = new Request(); 85 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, true, [ 86 | new QueryParams() 87 | ]); 88 | 89 | $resolver->resolve($request, $argument); 90 | } 91 | 92 | /** @test */ 93 | public function resolveForArgumentWithoutTypeFails() 94 | { 95 | $this->expectException(\InvalidArgumentException::class); 96 | $this->expectExceptionMessage('Argument "foo" must have the type specified'); 97 | 98 | $converter = $this->createMock(ConverterInterface::class); 99 | $resolver = new QueryParamsValueResolver($converter); 100 | $request = new Request(); 101 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 102 | new QueryParams() 103 | ]); 104 | 105 | $resolver->resolve($request, $argument); 106 | } 107 | 108 | /** @test */ 109 | public function resolveForArgumentWithNoConcreteClassTypeFails() 110 | { 111 | $this->expectException(\InvalidArgumentException::class); 112 | $this->expectExceptionMessage('Argument "foo" must be of concrete class type'); 113 | 114 | $converter = $this->createMock(ConverterInterface::class); 115 | $resolver = new QueryParamsValueResolver($converter); 116 | $request = new Request(); 117 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, false, [ 118 | new QueryParams() 119 | ]); 120 | 121 | $resolver->resolve($request, $argument); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Http/EntityResponse.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @final 15 | */ 16 | class EntityResponse extends Response 17 | { 18 | private const MEDIA_TYPE_ALL = '*/*'; 19 | 20 | private mixed $entity; 21 | 22 | public function __construct(mixed $entity, int $status = 200, array $headers = []) 23 | { 24 | parent::__construct('', $status, $headers); 25 | 26 | $this->setEntity($entity); 27 | } 28 | 29 | /** 30 | * Sets the entity that is returned in the response. 31 | */ 32 | public function setEntity(mixed $entity): static 33 | { 34 | $this->entity = $entity; 35 | $this->setContent(''); 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * Returns the entity that is returned in the response. 42 | */ 43 | public function getEntity(): mixed 44 | { 45 | return $this->entity; 46 | } 47 | 48 | /** 49 | * Executed later. 50 | * 51 | * @see EntityResponse::negotiateContent() 52 | */ 53 | public function prepare(Request $request): static 54 | { 55 | return $this; 56 | } 57 | 58 | /** 59 | * Negotiates the response content. 60 | * 61 | * The entity is mapped to the selected content type using 62 | * the content negotiation. 63 | */ 64 | public function negotiateContent(Request $request, MessageBodyMapperManager $messageBodyMapperManager, string $defaultContentType): static 65 | { 66 | $defaultContentType = MediaTypeDescriptor::parse($defaultContentType); 67 | $acceptableMediaTypes = $this->resolveAcceptableMediaTypes($request) ?: [$defaultContentType]; 68 | $supportedMediaTypes = MediaTypeDescriptor::parseList($messageBodyMapperManager->getSupportedMediaTypes()); 69 | 70 | if (!$supportedMediaTypes) { 71 | throw new \LogicException('You need to register at least one message body mapper for an entity response. For a JSON content type, you can use the built-in message body mapper by running "composer require symfony/serializer".'); 72 | } 73 | 74 | $contentType = $this->selectResponseContentType($acceptableMediaTypes, $supportedMediaTypes); 75 | if (!$contentType) { 76 | throw new NotAcceptableMediaTypeException(MediaTypeDescriptor::listToString($acceptableMediaTypes), MediaTypeDescriptor::listToString($supportedMediaTypes), 'Could not select any content type for response.'); 77 | } 78 | 79 | $this->headers->set('Content-Type', (string) $contentType); 80 | $this->setContent($messageBodyMapperManager->mapTo($this->entity, (string) $contentType)); 81 | 82 | parent::prepare($request); 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @param MediaTypeDescriptor[] $acceptableMediaTypes 89 | * @param MediaTypeDescriptor[] $supportedMediaTypes 90 | */ 91 | private function selectResponseContentType(array $acceptableMediaTypes, array $supportedMediaTypes): ?MediaTypeDescriptor 92 | { 93 | foreach ($acceptableMediaTypes as $acceptableMediaType) { 94 | foreach ($supportedMediaTypes as $supportedMediaType) { 95 | if ($acceptableMediaType->inRange($supportedMediaType)) { 96 | return $supportedMediaType; 97 | } 98 | } 99 | } 100 | 101 | return null; 102 | } 103 | 104 | /** 105 | * Resolves from: 106 | * 1. Request format 107 | * 2. Accept header 108 | * 109 | * @return MediaTypeDescriptor[] 110 | */ 111 | private function resolveAcceptableMediaTypes(Request $request): array 112 | { 113 | $format = $request->getRequestFormat(null); 114 | $mediaType = null !== $format ? $request->getMimeType($format) : null; 115 | 116 | if (null !== $format && null !== $mediaType && null !== $descriptor = MediaTypeDescriptor::parseOrNull($mediaType)) { 117 | return [$descriptor]; 118 | } 119 | 120 | if ($acceptableContentTypes = $request->getAcceptableContentTypes()) { 121 | // acceptable content types are already sorted 122 | $descriptors = []; 123 | foreach ($acceptableContentTypes as $contentType) { 124 | // [ignored] Accept: */* 125 | if (self::MEDIA_TYPE_ALL !== $contentType && null !== $descriptor = MediaTypeDescriptor::parseOrNull($contentType)) { 126 | $descriptors[] = $descriptor; 127 | } 128 | } 129 | 130 | if ($descriptors) { 131 | return $descriptors; 132 | } 133 | } 134 | 135 | return []; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/Http/MessageBodyMapperManagerTest.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class MessageBodyMapperManagerTest extends TestCase 15 | { 16 | /** @test */ 17 | public function supportedMediaTypes() 18 | { 19 | $manager = new MessageBodyMapperManager($this->createDefaultServiceLocator()); 20 | $this->assertEquals(['application/json', 'application/xml'], $manager->getSupportedMediaTypes()); 21 | } 22 | 23 | /** @test */ 24 | public function convertFromMessageBody() 25 | { 26 | $type = 'stdClass'; 27 | $jsonData = '{"hello": "json-world"}'; 28 | $xmlData = 'xml-world'; 29 | 30 | $expectedJsonObject = new \stdClass(); 31 | $expectedJsonObject->hello = 'json-world'; 32 | $expectedXmlObject = new \stdClass(); 33 | $expectedXmlObject->hello = 'xml-world'; 34 | 35 | $manager = new MessageBodyMapperManager(new ServiceLocator(array( 36 | 'application/json' => function () use ($jsonData, $type, $expectedJsonObject) { 37 | $mock = $this->createMock(MapperInterface::class); 38 | $mock 39 | ->expects($this->once()) 40 | ->method('mapFrom') 41 | ->with($jsonData, $type) 42 | ->willReturn($expectedJsonObject); 43 | 44 | return $mock; 45 | }, 46 | 'application/xml' => function () use ($xmlData, $type, $expectedXmlObject) { 47 | $mock = $this->createMock(MapperInterface::class); 48 | $mock 49 | ->expects($this->once()) 50 | ->method('mapFrom') 51 | ->with($xmlData, $type) 52 | ->willReturn($expectedXmlObject); 53 | 54 | return $mock; 55 | }, 56 | ))); 57 | 58 | $this->assertEquals($expectedJsonObject, $manager->mapFrom($jsonData, 'application/json', $type), 'JSON'); 59 | $this->assertEquals($expectedXmlObject, $manager->mapFrom($xmlData, 'application/xml', $type), 'XML'); 60 | } 61 | 62 | /** @test */ 63 | public function convertFromMessageBodyOnNonSupportedMediaType() 64 | { 65 | $this->expectException(UnsupportedMediaTypeException::class); 66 | 67 | $manager = new MessageBodyMapperManager($this->createDefaultServiceLocator()); 68 | $manager->mapFrom('foo', 'text/csv', 'stdClass'); 69 | } 70 | 71 | /** @test */ 72 | public function convertToMessageBody() 73 | { 74 | $expectedJsonData = '{"hello": "json-world"}'; 75 | $expectedXmlData = 'xml-world'; 76 | 77 | $jsonObject = new \stdClass(); 78 | $jsonObject->hello = 'json-world'; 79 | $xmlObject = new \stdClass(); 80 | $xmlObject->hello = 'xml-world'; 81 | 82 | $manager = new MessageBodyMapperManager(new ServiceLocator(array( 83 | 'application/json' => function () use ($jsonObject, $expectedJsonData) { 84 | $mock = $this->createMock(MapperInterface::class); 85 | $mock 86 | ->expects($this->once()) 87 | ->method('mapTo') 88 | ->with($jsonObject) 89 | ->willReturn($expectedJsonData); 90 | 91 | return $mock; 92 | }, 93 | 'application/xml' => function () use ($xmlObject, $expectedXmlData) { 94 | $mock = $this->createMock(MapperInterface::class); 95 | $mock 96 | ->expects($this->once()) 97 | ->method('mapTo') 98 | ->with($xmlObject) 99 | ->willReturn($expectedXmlData); 100 | 101 | return $mock; 102 | }, 103 | ))); 104 | 105 | $this->assertEquals($expectedJsonData, $manager->mapTo($jsonObject, 'application/json'), 'JSON'); 106 | $this->assertEquals($expectedXmlData, $manager->mapTo($xmlObject, 'application/xml'), 'XML'); 107 | } 108 | 109 | /** @test */ 110 | public function convertToMessageBodyOnNonSupportedMediaType() 111 | { 112 | $this->expectException(UnsupportedMediaTypeException::class); 113 | 114 | $manager = new MessageBodyMapperManager($this->createDefaultServiceLocator()); 115 | $manager->mapTo(new \stdClass(), 'text/csv'); 116 | } 117 | 118 | private function createDefaultServiceLocator(): ServiceLocator 119 | { 120 | return new ServiceLocator(array( 121 | 'application/json' => function () { return $this->createMock(MapperInterface::class); }, 122 | 'application/xml' => function () { return $this->createMock(MapperInterface::class); }, 123 | )); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver/RequestBodyValueResolver.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class RequestBodyValueResolver implements ArgumentValueResolverInterface 22 | { 23 | private const DEFAULT_CONTENT_TYPE = 'application/json'; 24 | 25 | private MessageBodyMapperManager $messageBodyMapperManager; 26 | private ConverterInterface $converter; 27 | private string $defaultContentType; 28 | 29 | private static array $fileClassTypes = [ 30 | UploadedFile::class, 31 | File::class, 32 | \SplFileInfo::class, 33 | \SplFileObject::class, 34 | ]; 35 | 36 | public function __construct(MessageBodyMapperManager $messageBodyMapperManager, ConverterInterface $converter, string $defaultContentType = self::DEFAULT_CONTENT_TYPE) 37 | { 38 | $this->messageBodyMapperManager = $messageBodyMapperManager; 39 | $this->converter = $converter; 40 | $this->defaultContentType = $defaultContentType; 41 | } 42 | 43 | public function supports(Request $request, ArgumentMetadata $argument): bool 44 | { 45 | return (bool) $argument->getAttributes(RequestBody::class, ArgumentMetadata::IS_INSTANCEOF); 46 | } 47 | 48 | public function resolve(Request $request, ArgumentMetadata $argument): array 49 | { 50 | /** @var RequestBody|null $attribute */ 51 | $attribute = $argument->getAttributes(RequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; 52 | 53 | if (null === $attribute) { 54 | return []; 55 | } 56 | 57 | if (!$argument->getType()) { 58 | throw new \InvalidArgumentException(sprintf('Argument "%s" must have the type specified for the request body conversion.', $argument->getName())); 59 | } 60 | 61 | $argumentType = $attribute->type() ?: $argument->getType(); 62 | 63 | // when request parameters are available 64 | if ($parameters = array_replace_recursive($request->request->all(), $request->files->all())) { 65 | try { 66 | return [$this->converter->convert($parameters, $argumentType)]; 67 | } catch (TypeConversionException $e) { 68 | throw new BadRequestHttpException('Request body parameters are invalid.', $e); 69 | } 70 | } 71 | 72 | $contentType = $request->headers->get('CONTENT_TYPE'); 73 | if (null === $contentType && '' !== $request->getContent()) { 74 | $contentType = $this->defaultContentType; 75 | } 76 | 77 | // empty body && unavailable content type 78 | if (null === $contentType) { 79 | $value = $argument->hasDefaultValue() ? $argument->getDefaultValue() : null; 80 | if ($value === null && !$argument->isNullable()) { 81 | throw new BadRequestHttpException('Request body cannot be empty.'); 82 | } 83 | 84 | return [$value]; 85 | } 86 | 87 | // file as the request body 88 | if (in_array($argumentType, self::$fileClassTypes, true)) { 89 | $filename = null; 90 | 91 | if ($headerValue = $request->headers->get('CONTENT_DISPOSITION')) { 92 | $contentDisposition = ContentDispositionDescriptor::parse($headerValue); 93 | $filename = $contentDisposition->isInline() ? $contentDisposition->getFilename() : null; 94 | } 95 | 96 | return [$this->convertToFile($request->getContent(true), $contentType, $argumentType, $filename)]; 97 | } 98 | 99 | try { 100 | return [$this->messageBodyMapperManager->mapFrom($request->getContent(), $contentType, $argumentType)]; 101 | } catch (MalformedDataException $e) { 102 | throw new BadRequestHttpException('Request body is malformed.', $e); 103 | } 104 | } 105 | 106 | private function convertToFile($resource, string $mediaType, string $type, ?string $filename): UploadedFile|File|\SplFileObject|\SplFileInfo 107 | { 108 | $tmpFile = TmpFileUtils::fromResource($resource); 109 | 110 | return match ($type) { 111 | UploadedFile::class => new UploadedFile($tmpFile, $filename ?: '', $mediaType, UPLOAD_ERR_OK, true), 112 | File::class => new File($tmpFile, false), 113 | 'SplFileObject' => new \SplFileObject($tmpFile), 114 | 'SplFileInfo' => new \SplFileInfo($tmpFile), 115 | default => throw new \InvalidArgumentException(sprintf('Unknown type "%s".', $type)), 116 | }; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [2.1.0] - 2022-12-07 11 | 12 | ### Changed 13 | - Adapted new `ValueResolverInterface` (6.2) in all value argument resolvers 14 | 15 | ## [2.0.0] - 2022-03-12 16 | 17 | ### Added 18 | - `EntityResponse` - an HTTP response with an entity that is mapped to the selected content type using the content negotiation. 19 | 20 | ### Changed 21 | - Transition to PHP v8.0 22 | - Transition to Symfony v6.0 23 | - `ConverterManager` no longer checks whether the passed value is already of the given type. Now, each converter should 24 | take care of this itself. 25 | - `SerializerObjectConverterAdapter` to `SerializerConverterAdapter`. It no longer checks if the type is an object type, 26 | it fully delegates to the adapted denormalizer. 27 | 28 | ### Removed 29 | - Annotations 30 | - Conversion of `array|object` types by `BuiltinTypeSafeConverter`. 31 | - Deprecated configuration option `entity_response.default_content_type`. 32 | - Attribute `ResponseBody`, use `EntityResponse` instead. 33 | 34 | ## [1.4.2] - 2022-01-03 35 | 36 | ### Fixed 37 | - Deprecations of missing return type declarations. 38 | 39 | ## [1.4.1] - 2021-09-22 40 | 41 | ### Fixed 42 | - Deprecated PHP 8.1 "null" on the 2nd argument of the `InvalidArgumentException` in `DefaultObjectExporter`. 43 | 44 | ## [1.4.0] - 2021-08-29 45 | 46 | ### Changed 47 | - Attributes are taken either directly from the `ArgumentMetadata` or from the attribute locator. 48 | - Annotations (not attributes) will be only taken into account when the `doctrine\annotations` package is available (in no-dev mode). 49 | 50 | ### Deprecated 51 | - `onAttribute()` and `onAnnotation()` methods on `RequestBodyValueResolver`, use the constructor instead 52 | - `onAttribute()` and `onAnnotation()` methods on `RequestCookieValueResolver`, use the constructor instead 53 | - `onAttribute()` and `onAnnotation()` methods on `RequestHeaderValueResolver`, use the constructor instead 54 | - `onAttribute()` and `onAnnotation()` methods on `RequestParamValueResolver`, use the constructor instead 55 | - `onAttribute()` and `onAnnotation()` methods on `QueryParamValueResolver`, use the constructor instead 56 | - `onAttribute()` and `onAnnotation()` methods on `QueryParamsValueResolver`, use the constructor instead 57 | 58 | ### Removed 59 | - Support for Symfony lower than v5.3 60 | 61 | ## [1.3.0] - 2021-07-31 62 | 63 | ### Added 64 | - Added support of nullable `RequestBody` arguments. When a request body is empty, and the content type is unavailable, a default argument value is used, or `null` in case of a nullable argument. 65 | 66 | ### Changed 67 | - No error for nullable `RequestBody` arguments. 68 | 69 | ### Removed 70 | - Doctrine annotations from the composer dependencies. 71 | - Support for Symfony v4.4 72 | 73 | ## [1.2.0] - 2020-09-27 74 | 75 | ### Added 76 | - Attributes (PHP 8.0). 77 | 78 | ### Changed 79 | - Refreshed the named value argument value resolvers. Simplified the `getArgumentValue` method signature by using the new `NamedValueArgument`. 80 | - Attributes are now key part of the bundle, annotations are used as adapters for attributes and are intended for projects basing on <= PHP 7.4. 81 | - Updated all argument value resolvers to support both annotations and attributes. 82 | - Changed internal `Jungi\FrameworkExtraBundle\Annotation\AbstractAnnotation` to `StatefulTrait`. 83 | 84 | ### Removed 85 | - internal `Jungi\FrameworkExtraBundle\Annotation\NamedValueArgument`. 86 | 87 | ### Fixed 88 | - Detecting duplicated annotations on argument by `RegisterControllerAnnotationLocatorsPass`. 89 | - Deprecation of ReflectionParameter::isArray() in `RegisterControllerAnnotationLocatorsPass`. 90 | 91 | ## [1.1.0] - 2020-09-11 92 | 93 | ### Added 94 | - Use the default content type `application/json` (can be overwritten in the configuration) when the request `Content-Type` is unavailable in the `RequestBodyValueResolver`. 95 | - Information about no registered message body mappers when creating an entity response. 96 | 97 | ### Changed 98 | - Moved validation of `@RequestBody` argument type from `RequestBodyValueResolver` to `RegisterControllerAnnotationLocatorsPass`. 99 | - Extended ability of mapping data to any type in `MapperInterface`. 100 | - Use mappers instead of converters on non-object types (scalars, collections) in the `RequestBodyValueResolver`. 101 | - Instead of 406 HTTP response, return 500 HTTP response in case of no registered message body mapper. 102 | 103 | ### Fixed 104 | - Handle exception on mapping to an array when scalar data has been provided in `SerializerMapperAdapter`. 105 | - Typo in the message of not acceptable http exception. 106 | 107 | ### Deprecated 108 | - Config option "default_content_type" at "entity_response". Moved to the root node "jungi_framework_extra". 109 | 110 | [unreleased]: https://github.com/jungi-php/framework-extra-bundle/compare/v2.1.0...HEAD 111 | [2.1.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v2.0.0...v2.1.0 112 | [2.0.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.4.2...v2.0.0 113 | [1.4.2]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.4.1...v1.4.2 114 | [1.4.1]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.4.0...v1.4.1 115 | [1.4.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.3.0...v1.4.0 116 | [1.3.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.2.0...v1.3.0 117 | [1.2.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.1.0...v1.2.0 118 | [1.1.0]: https://github.com/jungi-php/framework-extra-bundle/compare/v1.0.0...v1.1.0 119 | -------------------------------------------------------------------------------- /tests/Http/EntityResponseTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class EntityResponseTest extends TestCase 16 | { 17 | /** @test */ 18 | public function entityResponse() 19 | { 20 | $response = new EntityResponse('foo', 201, ['Foo' => 'bar']); 21 | 22 | $this->assertEquals('foo', $response->getEntity()); 23 | $this->assertSame('', $response->getContent()); 24 | $this->assertSame('bar', $response->headers->get('Foo')); 25 | $this->assertEquals(201, $response->getStatusCode()); 26 | } 27 | 28 | /** @test */ 29 | public function entityIsAltered() 30 | { 31 | $response = new EntityResponse('foo', 201, ['Foo' => 'bar']); 32 | $response->setContent('"foo"'); 33 | 34 | $response->setEntity('bar'); 35 | 36 | $this->assertEquals('bar', $response->getEntity()); 37 | $this->assertEquals('', $response->getContent()); 38 | } 39 | 40 | /** @test */ 41 | public function prepareDoesNothing() 42 | { 43 | $request = new Request(); 44 | $request->setRequestFormat('json'); 45 | $response = new EntityResponse('foo', 200); 46 | 47 | $response->prepare($request); 48 | 49 | $this->assertFalse($response->headers->has('Content-Type')); 50 | } 51 | 52 | /** @test */ 53 | public function entityIsMappedToRequestFormat() 54 | { 55 | $request = new Request(); 56 | $request->setRequestFormat('json'); 57 | 58 | $this->assertThatContentIsNegotiatedFor($request, 'application/json', 'application/xml'); 59 | } 60 | 61 | /** @test */ 62 | public function entityIsMappedToDefaultContentType() 63 | { 64 | $this->assertThatContentIsNegotiatedFor(new Request(), 'application/json', 'application/json'); 65 | } 66 | 67 | /** @test */ 68 | public function entityIsMappedToDefaultContentTypeOnInvalidRequestFormat() 69 | { 70 | $request = new Request(); 71 | $request->setRequestFormat('invalid'); 72 | 73 | $this->assertThatContentIsNegotiatedFor($request, 'application/xml', 'application/xml'); 74 | } 75 | 76 | /** @test */ 77 | public function entityIsMappedToDefaultContentTypeOnInvalidAcceptableMediaType() 78 | { 79 | $request = new Request(); 80 | $request->headers->set('Accept', '*/xml'); 81 | 82 | $this->assertThatContentIsNegotiatedFor($request, 'application/json', 'application/json'); 83 | } 84 | 85 | /** 86 | * @test 87 | * @dataProvider provideAcceptableMediaTypes 88 | */ 89 | public function entityIsMappedToAcceptableMediaType(string $expectedContentType, string $acceptedMediaTypes, string $defaultContentType) 90 | { 91 | $request = new Request(); 92 | $request->headers->set('Accept', $acceptedMediaTypes); 93 | 94 | $this->assertThatContentIsNegotiatedFor($request, $expectedContentType, $defaultContentType); 95 | } 96 | 97 | /** @test */ 98 | public function entityMappingFailsOnNoRegisteredMessageBodyMappers() 99 | { 100 | $this->expectException('LogicException'); 101 | $this->expectExceptionMessage('You need to register at least one message body mapper'); 102 | 103 | $response = new EntityResponse('foo'); 104 | $response->negotiateContent(new Request(), new MessageBodyMapperManager(new ServiceLocator([])), 'application/json'); 105 | } 106 | 107 | /** @test */ 108 | public function entityMappingFailsOnNotAcceptableMediaType() 109 | { 110 | $this->expectException(NotAcceptableMediaTypeException::class); 111 | 112 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 113 | $messageBodyMapperManager 114 | ->method('getSupportedMediaTypes') 115 | ->willReturn(['application/json', 'application/vnd.jungi.test']); 116 | 117 | $request = new Request(); 118 | $request->setRequestFormat('xml'); 119 | 120 | $response = new EntityResponse('foo'); 121 | $response->negotiateContent($request, $messageBodyMapperManager, 'application/json'); 122 | } 123 | 124 | public function provideAcceptableMediaTypes() 125 | { 126 | yield 'without a quality value' => [ 127 | 'application/xml', 128 | 'application/xml, application/vnd.jungi.test', 129 | 'application/json' 130 | ]; 131 | yield 'all with a quality value' => [ 132 | 'application/xml', 133 | 'application/vnd.jungi.test;q=0.1, application/xml;q=0.8', 134 | 'application/json', 135 | ]; 136 | yield 'some with a quality value' => [ 137 | 'application/vnd.jungi.test', 138 | 'application/json;q=0.5, application/vnd.jungi.test, application/xml;q=0.8', 139 | 'application/json', 140 | ]; 141 | yield 'empty' => ['application/json', '', 'application/json']; 142 | yield 'wildcard' => ['application/json', '*/*', 'application/json']; 143 | } 144 | 145 | private function assertThatContentIsNegotiatedFor(Request $request, string $expectedContentType, string $defaultContentType) 146 | { 147 | $entity = 'raw-entity'; 148 | $responseBody = 'converted-entity'; 149 | 150 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 151 | $messageBodyMapperManager 152 | ->expects($this->once()) 153 | ->method('mapTo') 154 | ->with($entity, $expectedContentType) 155 | ->willReturn($responseBody); 156 | $messageBodyMapperManager 157 | ->expects($this->once()) 158 | ->method('getSupportedMediaTypes') 159 | ->willReturn(['application/json', 'application/xml', 'application/vnd.jungi.test']); 160 | 161 | $response = new EntityResponse($entity); 162 | $response->negotiateContent($request, $messageBodyMapperManager, $defaultContentType); 163 | 164 | $this->assertEquals($expectedContentType, $response->headers->get('Content-Type')); 165 | $this->assertEquals($responseBody, $response->getContent()); 166 | } 167 | } 168 | 169 | -------------------------------------------------------------------------------- /tests/Converter/SerializerObjectConverterAdapterTest.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class SerializerObjectConverterAdapterTest extends TestCase 19 | { 20 | /** @test */ 21 | public function convertStringToDate() 22 | { 23 | $converter = new SerializerConverterAdapter(new DateTimeNormalizer()); 24 | 25 | $expected = new \DateTimeImmutable('1992-12-10 23:22:21'); 26 | $actual = $converter->convert('1992-12-10 23:22:21', \DateTimeImmutable::class); 27 | 28 | $this->assertEquals($expected, $actual); 29 | 30 | $expected = new \DateTime('1992-12-10 23:22:21'); 31 | $actual = $converter->convert('1992-12-10 23:22:21', \DateTime::class); 32 | 33 | $this->assertEquals($expected, $actual); 34 | } 35 | 36 | /** @test */ 37 | public function convertArrayToDto() 38 | { 39 | $extractors = [new ReflectionExtractor()]; 40 | $propertyInfo = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); 41 | $converter = new SerializerConverterAdapter(new Serializer([new PropertyNormalizer(null, null, $propertyInfo)])); 42 | 43 | $expected = new FooDto('hello-world', true, new Number(123), array('foo' => 'bar')); 44 | $actual = $converter->convert(array( 45 | 'stringVal' => 'hello-world', 46 | 'boolVal' => true, 47 | 'numberVal' => array('value' => 123), 48 | 'arrayVal' => array('foo' => 'bar'), 49 | ), FooDto::class); 50 | 51 | $this->assertEquals($expected, $actual); 52 | } 53 | 54 | /** @test */ 55 | public function convertStringArrayToDto() 56 | { 57 | $extractors = [new ReflectionExtractor()]; 58 | $propertyInfo = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); 59 | $converter = new SerializerConverterAdapter( 60 | new Serializer([new PropertyNormalizer(null, null, $propertyInfo)]), 61 | ['disable_type_enforcement' => true] 62 | ); 63 | 64 | $expected = new FooDto('hello-world', true, new Number(123), array('foo' => 'bar')); 65 | $actual = $converter->convert(array( 66 | 'stringVal' => 'hello-world', 67 | 'boolVal' => '1', 68 | 'numberVal' => array('value' => '123'), 69 | 'arrayVal' => array('foo' => 'bar'), 70 | ), FooDto::class); 71 | 72 | $this->assertEquals($expected, $actual); 73 | } 74 | 75 | /** @test */ 76 | public function convertPartiallyDenormalizedArrayToDto() 77 | { 78 | $extractors = [new ReflectionExtractor()]; 79 | $propertyInfo = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); 80 | $converter = new SerializerConverterAdapter( 81 | new Serializer([ 82 | new ObjectAlreadyOfTypeDenormalizer(), 83 | new PropertyNormalizer(null, null, $propertyInfo) 84 | ]), 85 | ['disable_type_enforcement' => true] 86 | ); 87 | 88 | $expected = new FooDto('hello-world', true, new Number(123), array('foo' => 'bar')); 89 | $actual = $converter->convert(array( 90 | 'stringVal' => 'hello-world', 91 | 'boolVal' => '1', 92 | 'numberVal' => new Number(123), // @see RequestBodyValueResolver: multipart/form-data file support 93 | 'arrayVal' => array('foo' => 'bar'), 94 | ), FooDto::class); 95 | 96 | $this->assertEquals($expected, $actual); 97 | } 98 | 99 | /** 100 | * @test 101 | * 102 | * Unfortunately the type safety is automatically abandoned when using the option "disable_type_enforcement". 103 | * Request parameters as well data decoded by XmlEncoder comes as a string, so the type casting is required 104 | * to properly convert it. 105 | */ 106 | public function convertArrayWithNonCastableTypeToDto() 107 | { 108 | $this->expectException(TypeConversionException::class); 109 | 110 | $extractors = [new ReflectionExtractor()]; 111 | $propertyInfo = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); 112 | $converter = new SerializerConverterAdapter(new Serializer( 113 | [new PropertyNormalizer(null, null, $propertyInfo)]), 114 | ['disable_type_enforcement' => true] 115 | ); 116 | 117 | $converter->convert(array( 118 | 'stringVal' => 'hello-world', 119 | 'boolVal' => '1', 120 | 'numberVal' => array('value' => 'invalid'), // string -> int (uncastable) = TypeError 121 | 'arrayVal' => array('num' => '1'), 122 | ), FooDto::class); 123 | } 124 | 125 | /** @test */ 126 | public function convertInvalidStringFormatToDate() 127 | { 128 | $this->expectException(TypeConversionException::class); 129 | 130 | $converter = new SerializerConverterAdapter(new DateTimeNormalizer(), ['datetime_format' => '!Y-m-d']); 131 | 132 | $converter->convert('10-12-1992', \DateTimeImmutable::class); 133 | } 134 | } 135 | 136 | class Number 137 | { 138 | private $value; 139 | 140 | public function __construct(int $value) 141 | { 142 | $this->value = $value; 143 | } 144 | } 145 | 146 | class FooDto 147 | { 148 | private $stringVal; 149 | private $boolVal; 150 | private $numberVal; 151 | private $arrayVal; 152 | 153 | public function __construct(string $stringVal, bool $boolVal, Number $numberVal, array $arrayVal) 154 | { 155 | $this->stringVal = $stringVal; 156 | $this->boolVal = $boolVal; 157 | $this->numberVal = $numberVal; 158 | $this->arrayVal = $arrayVal; 159 | } 160 | 161 | public function getStringVal(): string 162 | { 163 | return $this->stringVal; 164 | } 165 | 166 | public function getBoolVal(): bool 167 | { 168 | return $this->boolVal; 169 | } 170 | 171 | public function getNumberVal(): Number 172 | { 173 | return $this->numberVal; 174 | } 175 | 176 | public function getArrayVal(): array 177 | { 178 | return $this->arrayVal; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/Mapper/SerializerMapperAdapterTest.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class SerializerMapperAdapterTest extends TestCase 21 | { 22 | /** 23 | * @test 24 | * @dataProvider provideData 25 | */ 26 | public function mapFromData($expected, string $data, string $type) 27 | { 28 | $mapper = new SerializerMapperAdapter('json', new Serializer([ 29 | new GetSetMethodNormalizer(), 30 | new ArrayDenormalizer(), 31 | ], [ 32 | new JsonEncoder() 33 | ])); 34 | $this->assertEquals($expected, $mapper->mapFrom($data, $type)); 35 | } 36 | 37 | /** 38 | * @test 39 | * @group client_error 40 | * @dataProvider provideMalformedData 41 | */ 42 | public function mapFromMalformedData(string $message, string $type) 43 | { 44 | $this->expectException(MalformedDataException::class); 45 | 46 | $mapper = new SerializerMapperAdapter('json', new Serializer([ 47 | new GetSetMethodNormalizer(), 48 | new ArrayDenormalizer(), 49 | ], [ 50 | new JsonEncoder() 51 | ])); 52 | $mapper->mapFrom($message, $type); 53 | } 54 | 55 | /** 56 | * @test 57 | * @group client_error 58 | */ 59 | public function mapFromDataWithInvalidParameterType() 60 | { 61 | $this->expectException(MalformedDataException::class); 62 | $message = <<< 'EOXML' 63 | 64 | 65 | 66 | foo 67 | bar 68 | 69 | 70 | EOXML; 71 | 72 | $mapper = $this->createXmlSerializerMapperAdapter(true); 73 | $mapper->mapFrom($message, Foo::class); 74 | } 75 | 76 | /** 77 | * @test 78 | * @group client_error 79 | */ 80 | public function mapFromDataWithInvalidParameterTypeWithDisabledTypeEnforcement() 81 | { 82 | $this->expectException(MalformedDataException::class); 83 | $message = <<< 'EOXML' 84 | 85 | 86 | 87 | foo 88 | bar 89 | 90 | 91 | EOXML; 92 | 93 | $mapper = $this->createXmlSerializerMapperAdapter(true, ['disable_type_enforcement' => true]); 94 | $mapper->mapFrom($message, Foo::class); 95 | } 96 | 97 | /** 98 | * @test 99 | * @group client_error 100 | */ 101 | public function mapFromDataOnMissingConstructorArgument() 102 | { 103 | $this->expectException(MalformedDataException::class); 104 | $message = <<< 'EOXML' 105 | 106 | 107 | foo 108 | 109 | EOXML; 110 | 111 | $mapper = $this->createXmlSerializerMapperAdapter(true); 112 | $mapper->mapFrom($message, Foo::class); 113 | } 114 | 115 | /** 116 | * @test 117 | * @group server_error 118 | */ 119 | public function mapFromDataOnNonRegisteredNormalizer() 120 | { 121 | $this->expectException(\InvalidArgumentException::class); 122 | $message = <<< 'EOXML' 123 | 124 | 125 | xml-world 126 | 127 | EOXML; 128 | 129 | $mapper = new SerializerMapperAdapter('xml', new Serializer([new CustomNormalizer()], [new XmlEncoder()])); 130 | $mapper->mapFrom($message, Foo::class); 131 | } 132 | 133 | /** 134 | * @test 135 | * @group server_error 136 | */ 137 | public function createWithNotSupportedFormat() 138 | { 139 | $this->expectException(\InvalidArgumentException::class); 140 | 141 | new SerializerMapperAdapter('json', new Serializer([new CustomNormalizer()], [new XmlEncoder()])); 142 | } 143 | 144 | /** @test */ 145 | public function mapToData() 146 | { 147 | $mapper = new SerializerMapperAdapter('json', new Serializer([new GetSetMethodNormalizer()], [new JsonEncoder()])); 148 | $this->assertJsonStringEqualsJsonString('{"hello": "json-world"}', $mapper->mapTo(new Foo('json-world'))); 149 | } 150 | 151 | /** 152 | * @test 153 | * @group server_error 154 | */ 155 | public function mapToDataOnNonRegisteredNormalizer() 156 | { 157 | $this->expectException(\InvalidArgumentException::class); 158 | 159 | $mapper = new SerializerMapperAdapter('json', new Serializer([new CustomNormalizer()], [new JsonEncoder()])); 160 | $mapper->mapTo(new Foo('json-world')); 161 | } 162 | 163 | /** 164 | * @test 165 | */ 166 | public function mapToDataWhenNormalizationIsNotNeeded() 167 | { 168 | $mapper = new SerializerMapperAdapter('json', new Serializer([], [new JsonEncoder()])); 169 | $this->assertJsonStringEqualsJsonString('{"hello": "json-world"}', $mapper->mapTo(array('hello' => 'json-world'))); 170 | } 171 | 172 | public function provideData() 173 | { 174 | yield [new Foo('world'), '{"hello": "world"}', Foo::class]; 175 | yield ['hello world', '"hello world"', 'string']; 176 | yield [['hello', 'world'], '["hello", "world"]', 'string[]']; 177 | } 178 | 179 | public function provideMalformedData() 180 | { 181 | yield ['"hello world"', 'int']; 182 | yield ['[123]', Foo::class]; 183 | yield ['[123,234]', 'string[]']; 184 | yield ['123', 'string[]']; 185 | } 186 | 187 | private function createXmlSerializerMapperAdapter(bool $propertyInfoEnabled = false, array $context = []) 188 | { 189 | $propertyInfo = null; 190 | 191 | if ($propertyInfoEnabled) { 192 | $extractors = [new ReflectionExtractor()]; 193 | $propertyInfo = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); 194 | } 195 | 196 | return new SerializerMapperAdapter( 197 | 'xml', 198 | new Serializer([new GetSetMethodNormalizer(null, null, $propertyInfo)], [new XmlEncoder()]), 199 | $context 200 | ); 201 | } 202 | } 203 | 204 | class Foo 205 | { 206 | private $hello; 207 | 208 | public function __construct(string $hello) 209 | { 210 | $this->hello = $hello; 211 | } 212 | 213 | public function getHello(): string 214 | { 215 | return $this->hello; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/AbstractNamedValueArgumentValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class AbstractNamedValueArgumentValueResolverTest extends TestCase 20 | { 21 | /** @test */ 22 | public function supports() 23 | { 24 | $converter = $this->createMock(ConverterInterface::class); 25 | $resolver = new DummyRequestAttributeValueResolver($converter); 26 | $request = new Request(); 27 | 28 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 29 | new DummyRequestAttribute('foo') 30 | ]); 31 | $this->assertTrue($resolver->supports($request, $argument)); 32 | 33 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 34 | new ForeignAttribute() 35 | ]); 36 | $this->assertFalse($resolver->supports($request, $argument)); 37 | 38 | $argument = new ArgumentMetadata('bar', null, false, false, null); 39 | $this->assertFalse($resolver->supports($request, $argument)); 40 | } 41 | 42 | /** 43 | * @test 44 | * @dataProvider provideArgumentParameterNames 45 | */ 46 | public function parameterIsConverted(string $argumentName, ?string $parameterName) 47 | { 48 | $converter = $this->createMock(ConverterInterface::class); 49 | $converter 50 | ->expects($this->once()) 51 | ->method('convert') 52 | ->with('1992-12-10 23:23:23', \DateTimeImmutable::class) 53 | ->willReturn(new \DateTimeImmutable('1992-12-10 23:23:23')); 54 | 55 | $resolver = new DummyRequestAttributeValueResolver($converter); 56 | 57 | $request = new Request([], [], [ 58 | $parameterName ?: $argumentName => '1992-12-10 23:23:23' 59 | ]); 60 | $argument = new ArgumentMetadata($argumentName, \DateTimeImmutable::class, false, false, null, false, [ 61 | new DummyRequestAttribute($parameterName) 62 | ]); 63 | 64 | $values = $resolver->resolve($request, $argument); 65 | 66 | $this->assertCount(1, $values); 67 | $this->assertInstanceOf(\DateTimeImmutable::class, $values[0]); 68 | } 69 | 70 | /** @test */ 71 | public function parameterWithoutArgumentTypeIsNotConverted() 72 | { 73 | $converter = $this->createMock(ConverterInterface::class); 74 | $converter 75 | ->expects($this->never()) 76 | ->method('convert'); 77 | 78 | $resolver = new DummyRequestAttributeValueResolver($converter); 79 | $request = new Request([], [], [ 80 | 'foo' => 'bar' 81 | ]); 82 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 83 | new DummyRequestAttribute('foo') 84 | ]); 85 | 86 | $values = $resolver->resolve($request, $argument); 87 | 88 | $this->assertCount(1, $values); 89 | $this->assertEquals('bar', $values[0]); 90 | } 91 | 92 | /** @test */ 93 | public function parameterConversionFails() 94 | { 95 | $this->expectException(BadRequestHttpException::class); 96 | $this->expectExceptionMessage('Cannot convert named argument "foo"'); 97 | 98 | $converter = $this->createMock(ConverterInterface::class); 99 | $converter 100 | ->method('convert') 101 | ->willThrowException(new TypeConversionException('Type conversion failed.')); 102 | 103 | $resolver = new DummyRequestAttributeValueResolver($converter); 104 | $request = new Request([], [], [ 105 | 'foo' => 'bar' 106 | ]); 107 | $argument = new ArgumentMetadata('foo', \DateTimeImmutable::class, false, false, null, false, [ 108 | new DummyRequestAttribute('foo') 109 | ]); 110 | 111 | $resolver->resolve($request, $argument); 112 | } 113 | 114 | /** @test */ 115 | public function resolveForArgumentWithoutAttributeIsIgnored() 116 | { 117 | $converter = $this->createMock(ConverterInterface::class); 118 | $converter 119 | ->expects($this->never()) 120 | ->method('convert'); 121 | 122 | $resolver = new DummyRequestAttributeValueResolver($converter); 123 | $request = new Request(); 124 | $argument = new ArgumentMetadata('foo', null, false, false, null, false); 125 | 126 | $this->assertEmpty($resolver->resolve($request, $argument)); 127 | } 128 | 129 | /** @test */ 130 | public function resolveForVariadicArgumentFails() 131 | { 132 | $this->expectException(\InvalidArgumentException::class); 133 | $this->expectExceptionMessage('Variadic arguments are not supported'); 134 | 135 | $converter = $this->createMock(ConverterInterface::class); 136 | $resolver = new DummyRequestAttributeValueResolver($converter); 137 | $request = new Request(); 138 | $argument = new ArgumentMetadata('foo', null, true, false, null, false, [ 139 | new DummyRequestAttribute('foo') 140 | ]); 141 | 142 | $resolver->resolve($request, $argument); 143 | } 144 | 145 | /** @test */ 146 | public function resolveForNotNullableArgumentOnMissingParameterFails() 147 | { 148 | $this->expectException(BadRequestHttpException::class); 149 | $this->expectExceptionMessage('Argument "foo" cannot be found in the request'); 150 | 151 | $converter = $this->createMock(ConverterInterface::class); 152 | $resolver = new DummyRequestAttributeValueResolver($converter); 153 | $request = new Request(); 154 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, false, [ 155 | new DummyRequestAttribute('foo') 156 | ]); 157 | 158 | $resolver->resolve($request, $argument); 159 | } 160 | 161 | /** @test */ 162 | public function nullableArgumentOnMissingParameterIsResolvedToNullValue() 163 | { 164 | $converter = $this->createMock(ConverterInterface::class); 165 | $resolver = new DummyRequestAttributeValueResolver($converter); 166 | $request = new Request(); 167 | $argument = new ArgumentMetadata('foo', null, false, false, null, true, [ 168 | new DummyRequestAttribute('foo') 169 | ]); 170 | 171 | $values = $resolver->resolve($request, $argument); 172 | 173 | $this->assertCount(1, $values); 174 | $this->assertNull($values[0]); 175 | } 176 | 177 | /** @test */ 178 | public function argumentOnMissingParameterIsResolvedToDefaultValue() 179 | { 180 | $converter = $this->createMock(ConverterInterface::class); 181 | $resolver = new DummyRequestAttributeValueResolver($converter); 182 | $request = new Request(); 183 | $argument = new ArgumentMetadata('foo', null, false, true, 'bar', false, [ 184 | new DummyRequestAttribute('foo') 185 | ]); 186 | 187 | $values = $resolver->resolve($request, $argument); 188 | 189 | $this->assertCount(1, $values); 190 | $this->assertEquals('bar', $values[0]); 191 | } 192 | 193 | public function provideArgumentParameterNames(): iterable 194 | { 195 | yield 'parameter with name' => ['foo', 'bar']; 196 | yield 'parameter without name' => ['bar', null]; 197 | } 198 | } 199 | 200 | #[\Attribute] 201 | class DummyRequestAttribute implements NamedValue 202 | { 203 | public function __construct(private ?string $name = null) {} 204 | 205 | public function name(): ?string 206 | { 207 | return $this->name; 208 | } 209 | } 210 | 211 | class DummyRequestAttributeValueResolver extends AbstractNamedValueArgumentValueResolver 212 | { 213 | protected static string $attributeClass = DummyRequestAttribute::class; 214 | 215 | protected function getArgumentValue(NamedValueArgument $argument, Request $request): mixed 216 | { 217 | return $request->attributes->get($argument->getName()); 218 | } 219 | } -------------------------------------------------------------------------------- /tests/Controller/ArgumentResolver/RequestBodyValueResolverTest.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class RequestBodyValueResolverTest extends TestCase 23 | { 24 | /** @test */ 25 | public function supports() 26 | { 27 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 28 | $converter = $this->createMock(ConverterInterface::class); 29 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 30 | $request = new Request(); 31 | 32 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 33 | new RequestBody() 34 | ]); 35 | $this->assertTrue($resolver->supports($request, $argument)); 36 | 37 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 38 | new ForeignAttribute() 39 | ]); 40 | $this->assertFalse($resolver->supports($request, $argument)); 41 | 42 | $argument = new ArgumentMetadata('bar', 'stdClass', false, false, null); 43 | $this->assertFalse($resolver->supports($request, $argument)); 44 | } 45 | 46 | /** 47 | * @test 48 | * @dataProvider provideMappableTypes 49 | */ 50 | public function mapRequestBody(string $argumentType, ?string $annotatedAsType, mixed $value) 51 | { 52 | $converter = $this->createMock(ConverterInterface::class); 53 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 54 | $messageBodyMapperManager 55 | ->expects($this->once()) 56 | ->method('mapFrom') 57 | ->with('hello-world', 'application/vnd.jungi.test', $annotatedAsType ?: $argumentType) 58 | ->willReturn($value); 59 | 60 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 61 | 62 | $request = new Request([], [], [], [], [], [], 'hello-world'); 63 | $request->headers->set('Content-Type', 'application/vnd.jungi.test'); 64 | 65 | $argument = new ArgumentMetadata('foo', $argumentType, false, false, null, false, [ 66 | new RequestBody($annotatedAsType) 67 | ]); 68 | 69 | $values = $resolver->resolve($request, $argument); 70 | 71 | $this->assertCount(1, $values); 72 | $this->assertSame($value, $values[0]); 73 | } 74 | 75 | /** @test */ 76 | public function multipartFormDataRequest() 77 | { 78 | $file = new UploadedFile(__DIR__.'/../../Fixtures/uploaded_file', 'uploaded_file', 'text/plain'); 79 | $expectedData = array( 80 | 'hello' => 'world', 81 | 'attachments' => [array( 82 | 'name' => 'foo', 83 | 'file' => $file, 84 | )], 85 | ); 86 | 87 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 88 | $converter = $this->createMock(ConverterInterface::class); 89 | $converter 90 | ->expects($this->once()) 91 | ->method('convert') 92 | ->with($expectedData, 'stdClass') 93 | ->willReturn(new \stdClass()); 94 | 95 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 96 | 97 | $request = new Request([], array( 98 | 'hello' => 'world', 99 | 'attachments' => [array('name' => 'foo')], 100 | ), array( 101 | '_controller' => 'FooController' 102 | ), [], array( 103 | 'attachments' => [array( 104 | 'file' => $file 105 | )], 106 | )); 107 | $request->headers->set('Content-Type', 'multipart/form-data'); 108 | 109 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 110 | new RequestBody() 111 | ]); 112 | 113 | $values = $resolver->resolve($request, $argument); 114 | 115 | $this->assertCount(1, $values); 116 | $this->assertInstanceOf('stdClass', $values[0]); 117 | } 118 | 119 | /** 120 | * @test 121 | * @dataProvider provideRegularFileClassTypes 122 | */ 123 | public function regularFileRequest($type) 124 | { 125 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 126 | $converter = $this->createMock(ConverterInterface::class); 127 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 128 | 129 | $request = new Request([], [], [], [], [], [], 'hello,world'); 130 | $request->headers->set('Content-Type', 'text/csv'); 131 | 132 | $argument = new ArgumentMetadata('foo', $type, false, false, null, false, [ 133 | new RequestBody() 134 | ]); 135 | 136 | $values = $resolver->resolve($request, $argument); 137 | 138 | $this->assertCount(1, $values); 139 | $this->assertInstanceOf($type, $values[0]); 140 | $this->assertEquals('hello,world', $values[0]->openFile('r')->fread(32)); 141 | } 142 | 143 | /** @test */ 144 | public function uploadedFileRequest() 145 | { 146 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 147 | $converter = $this->createMock(ConverterInterface::class); 148 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 149 | 150 | $request = new Request([], [], [], [], [], [], 'hello,world'); 151 | $request->headers->set('Content-Type', 'text/csv'); 152 | $request->headers->set('Content-Disposition', 'inline; filename = "foo123.csv"'); 153 | 154 | $argument = new ArgumentMetadata('foo', UploadedFile::class, false, false, null, false, [ 155 | new RequestBody() 156 | ]); 157 | 158 | $values = $resolver->resolve($request, $argument); 159 | 160 | $this->assertCount(1, $values); 161 | $this->assertEquals('hello,world', $values[0]->openFile('r')->fread(32)); 162 | $this->assertEquals('text/csv', $values[0]->getClientMimeType()); 163 | $this->assertEquals('foo123.csv', $values[0]->getClientOriginalName()); 164 | $this->assertEquals('csv', $values[0]->getClientOriginalExtension()); 165 | $this->assertTrue($values[0]->isValid()); 166 | $this->assertEquals(UPLOAD_ERR_OK, $values[0]->getError()); 167 | 168 | $request->headers->set('Content-Disposition', 'attachment; filename = "foo123.csv"'); 169 | $values = $resolver->resolve($request, $argument); 170 | 171 | $this->assertCount(1, $values); 172 | $this->assertEmpty($values[0]->getClientOriginalName()); 173 | $this->assertEmpty($values[0]->getClientOriginalExtension()); 174 | } 175 | 176 | /** @test */ 177 | public function mapFromDefaultContentTypeOnUnavailableContentType() 178 | { 179 | $converter = $this->createMock(ConverterInterface::class); 180 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 181 | $messageBodyMapperManager 182 | ->expects($this->once()) 183 | ->method('mapFrom') 184 | ->with('123', 'application/vnd.jungi.test', 'int') 185 | ->willReturn(123); 186 | 187 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter, 'application/vnd.jungi.test'); 188 | $request = new Request([], [], [], [], [], [], '123'); 189 | $argument = new ArgumentMetadata('foo', 'int', false, false, null, false, [ 190 | new RequestBody() 191 | ]); 192 | 193 | $values = $resolver->resolve($request, $argument); 194 | 195 | $this->assertCount(1, $values); 196 | $this->assertEquals(123, $values[0]); 197 | } 198 | 199 | /** @test */ 200 | public function argumentOnEmptyRequestBodyAndUnavailableContentTypeIsResolvedToNullValue() 201 | { 202 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 203 | $converter = $this->createMock(ConverterInterface::class); 204 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 205 | 206 | $request = new Request(); 207 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, true, [ 208 | new RequestBody() 209 | ]); 210 | 211 | $values = $resolver->resolve($request, $argument); 212 | 213 | $this->assertCount(1, $values); 214 | $this->assertNull($values[0]); 215 | } 216 | 217 | /** @test */ 218 | public function mapOnEmptyRequestBodyAndAvailableContentType() 219 | { 220 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 221 | $messageBodyMapperManager 222 | ->expects($this->once()) 223 | ->method('mapFrom') 224 | ->with('', 'application/vnd.jungi.test', 'string') 225 | ->willReturnArgument(0); 226 | 227 | $converter = $this->createMock(ConverterInterface::class); 228 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 229 | 230 | $request = new Request(); 231 | $request->headers->set('Content-Type', 'application/vnd.jungi.test'); 232 | 233 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, true, [ 234 | new RequestBody() 235 | ]); 236 | 237 | $values = $resolver->resolve($request, $argument); 238 | 239 | $this->assertCount(1, $values); 240 | $this->assertSame('', $values[0]); 241 | } 242 | 243 | /** @test */ 244 | public function resolveForArgumentWithoutAttributeIsIgnored() 245 | { 246 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 247 | $messageBodyMapperManager 248 | ->expects($this->never()) 249 | ->method('mapFrom'); 250 | 251 | $converter = $this->createMock(ConverterInterface::class); 252 | $converter 253 | ->expects($this->never()) 254 | ->method('convert'); 255 | 256 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 257 | 258 | $request = new Request(); 259 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, false); 260 | 261 | $this->assertEmpty($resolver->resolve($request, $argument)); 262 | } 263 | 264 | /** @test */ 265 | public function resolveForNotNullableArgumentOnEmptyRequestBodyFails() 266 | { 267 | $this->expectException(BadRequestHttpException::class); 268 | $this->expectExceptionMessage('Request body cannot be empty'); 269 | 270 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 271 | $converter = $this->createMock(ConverterInterface::class); 272 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 273 | 274 | $request = new Request(); 275 | $argument = new ArgumentMetadata('foo', 'string', false, false, null, false, [ 276 | new RequestBody() 277 | ]); 278 | 279 | $resolver->resolve($request, $argument); 280 | } 281 | 282 | /** @test */ 283 | public function argumentOnEmptyRequestBodyIsResolvedToDefaultValue() 284 | { 285 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 286 | $converter = $this->createMock(ConverterInterface::class); 287 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 288 | 289 | $request = new Request(); 290 | $argument = new ArgumentMetadata('foo', 'int', false, true, 123, false, [ 291 | new RequestBody() 292 | ]); 293 | 294 | $values = $resolver->resolve($request, $argument); 295 | 296 | $this->assertCount(1, $values); 297 | $this->assertEquals(123, $values[0]); 298 | } 299 | 300 | /** @test */ 301 | public function resolveForArgumentWithoutTypeFails() 302 | { 303 | $this->expectException(\InvalidArgumentException::class); 304 | $this->expectExceptionMessage('Argument "foo" must have the type specified'); 305 | 306 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 307 | $converter = $this->createMock(ConverterInterface::class); 308 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 309 | 310 | $request = new Request(); 311 | $argument = new ArgumentMetadata('foo', null, false, false, null, false, [ 312 | new RequestBody() 313 | ]); 314 | 315 | $resolver->resolve($request, $argument); 316 | } 317 | 318 | /** @test */ 319 | public function malformedRequestBody() 320 | { 321 | $this->expectException(BadRequestHttpException::class); 322 | $this->expectExceptionMessage('Request body is malformed'); 323 | 324 | $converter = $this->createMock(ConverterInterface::class); 325 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 326 | $messageBodyMapperManager 327 | ->expects($this->once()) 328 | ->method('mapFrom') 329 | ->willThrowException(new MalformedDataException('Malformed data')); 330 | 331 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 332 | 333 | $request = new Request(); 334 | $request->headers->set('Content-Type', 'application/json'); 335 | 336 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 337 | new RequestBody() 338 | ]); 339 | 340 | $resolver->resolve($request, $argument); 341 | } 342 | 343 | /** @test */ 344 | public function invalidRequestBodyParameters() 345 | { 346 | $this->expectException(BadRequestHttpException::class); 347 | $this->expectExceptionMessage('Request body parameters are invalid'); 348 | 349 | $messageBodyMapperManager = $this->createMock(MessageBodyMapperManager::class); 350 | $converter = $this->createMock(ConverterInterface::class); 351 | $converter 352 | ->expects($this->once()) 353 | ->method('convert') 354 | ->willThrowException(new TypeConversionException('Cannot convert data')); 355 | 356 | $resolver = new RequestBodyValueResolver($messageBodyMapperManager, $converter); 357 | 358 | $request = new Request([], ['foo' => 'bar']); 359 | $request->headers->set('Content-Type', 'application/x-www-form-urlencoded'); 360 | 361 | $argument = new ArgumentMetadata('foo', 'stdClass', false, false, null, false, [ 362 | new RequestBody() 363 | ]); 364 | 365 | $resolver->resolve($request, $argument); 366 | } 367 | 368 | public function provideRegularFileClassTypes(): iterable 369 | { 370 | yield [File::class]; 371 | yield ['SplFileInfo']; 372 | yield ['SplFileObject']; 373 | } 374 | 375 | public function provideMappableTypes(): iterable 376 | { 377 | yield ['string', null, 'hello-world']; 378 | yield ['stdClass', null, new \stdClass()]; 379 | yield ['array', 'stdClass[]', []]; 380 | yield ['array', 'string[]', []]; 381 | } 382 | } 383 | --------------------------------------------------------------------------------