├── 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 |
22 |
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 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
38 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/config/attributes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
24 |
28 |
32 |
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 | [](https://github.com/jungi-php/framework-extra-bundle/actions)
4 | 
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 |
--------------------------------------------------------------------------------