diff --git a/src/Filesystem/Attribute/PendingUploadedFile.php b/src/Filesystem/Attribute/PendingUploadedFile.php new file mode 100644 index 00000000..bba1e2dd --- /dev/null +++ b/src/Filesystem/Attribute/PendingUploadedFile.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Attribute; + +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Validator\Constraints\All; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Node\File\Image; +use Zenstruck\Filesystem\Symfony\Validator\PendingImageConstraint; + +/** + * @author Jakub Caban + * + * @phpstan-consistent-constructor + * @readonly + */ +#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)] +class PendingUploadedFile +{ + public function __construct( + public ?string $path = null, + public ?array $constraints = null, + public ?bool $image = null, + ) { + } + + /** + * @internal + */ + public static function forArgument(ArgumentMetadata $argument): self + { + $attributes = $argument->getAttributes(self::class, ArgumentMetadata::IS_INSTANCEOF); + + if (!empty($attributes)) { + $attribute = $attributes[0]; + \assert($attribute instanceof self); + } else { + $attribute = new static(); + } + + $attribute->path ??= $argument->getName(); + + $attribute->image ??= \is_a( + $argument->getType() ?? File::class, + Image::class, + true + ); + + if (null === $attribute->constraints && $attribute->image) { + if ('array' === $argument->getType()) { + $attribute->constraints = [ + new All([new PendingImageConstraint()]), + ]; + } else { + $attribute->constraints = [new PendingImageConstraint()]; + } + } + + return $attribute; + } +} diff --git a/src/Filesystem/Attribute/UploadedFile.php b/src/Filesystem/Attribute/UploadedFile.php new file mode 100644 index 00000000..dd23aa26 --- /dev/null +++ b/src/Filesystem/Attribute/UploadedFile.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Attribute; + +use Zenstruck\Filesystem\Node\Path\Expression; +use Zenstruck\Filesystem\Node\Path\Namer; + +/** + * @author Jakub Caban + * + * @readonly + */ +#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)] +final class UploadedFile extends PendingUploadedFile +{ + public string|Namer $namer; + + public function __construct( + public string $filesystem, + string|Namer|null $namer = null, + ?string $path = null, + ?array $constraints = null, + ?bool $image = null, + ) { + parent::__construct($path, $constraints, $image); + + $this->namer = $namer ?? new Expression('{checksum}/{name}{ext}'); + } +} diff --git a/src/Filesystem/Glide/GlideTransformUrlGenerator.php b/src/Filesystem/Glide/GlideTransformUrlGenerator.php index 0ea30e3e..b9dfc034 100644 --- a/src/Filesystem/Glide/GlideTransformUrlGenerator.php +++ b/src/Filesystem/Glide/GlideTransformUrlGenerator.php @@ -28,7 +28,7 @@ public function transformUrl(string $path, array|string $filter, Config $config) { $filter = match (true) { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/8937 \is_string($filter) => ['p' => $filter], // is glide "preset" - \is_array($filter) && !array_is_list($filter) => $filter, // is standard glide parameters + \is_array($filter) && !\array_is_list($filter) => $filter, // is standard glide parameters \is_array($filter) => ['p' => \implode(',', $filter)], // is array of "presets" }; diff --git a/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php b/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php index 3b56797b..e4248fc7 100644 --- a/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php +++ b/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php @@ -25,6 +25,7 @@ use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\String\Slugger\SluggerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\LocaleAwareInterface; use Zenstruck\Filesystem; use Zenstruck\Filesystem\Doctrine\EventListener\NodeLifecycleListener; @@ -45,6 +46,8 @@ use Zenstruck\Filesystem\Symfony\Command\FilesystemPurgeCommand; use Zenstruck\Filesystem\Symfony\Form\PendingFileType; use Zenstruck\Filesystem\Symfony\HttpKernel\FilesystemDataCollector; +use Zenstruck\Filesystem\Symfony\HttpKernel\PendingFileValueResolver; +use Zenstruck\Filesystem\Symfony\HttpKernel\RequestFilesExtractor; use Zenstruck\Filesystem\Symfony\Routing\RoutePublicUrlGenerator; use Zenstruck\Filesystem\Symfony\Routing\RouteTemporaryUrlGenerator; use Zenstruck\Filesystem\Symfony\Routing\RouteTransformUrlGenerator; @@ -155,6 +158,22 @@ private function registerDoctrine(ContainerBuilder $container, array $config): v $listener->addTag('doctrine.event_listener', ['event' => 'postRemove']); } + // value resolver + $container->register('.zenstruck_document.value_resolver.request_files_extractor', RequestFilesExtractor::class) + ->addArgument(new Reference('property_accessor')) + ; + $container->register('.zenstruck_document.value_resolver.pending_document', PendingFileValueResolver::class) + ->addTag('controller.argument_value_resolver', ['priority' => 110]) + ->addArgument( + new ServiceLocatorArgument([ + FilesystemRegistry::class => new Reference(FilesystemRegistry::class), + PathGenerator::class => new Reference(PathGenerator::class), + RequestFilesExtractor::class => new Reference('.zenstruck_document.value_resolver.request_files_extractor'), + ValidatorInterface::class => new Reference(ValidatorInterface::class), + ]) + ) + ; + if (isset($container->getParameter('kernel.bundles')['TwigBundle'])) { $container->register('.zenstruck_filesystem.doctrine.twig_extension', MappingManagerExtension::class) ->addTag('twig.extension') diff --git a/src/Filesystem/Symfony/Exception/IncorrectFileHttpException.php b/src/Filesystem/Symfony/Exception/IncorrectFileHttpException.php new file mode 100644 index 00000000..ef3d94a2 --- /dev/null +++ b/src/Filesystem/Symfony/Exception/IncorrectFileHttpException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Symfony\Exception; + +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; + +/** + * @author Jakub Caban + */ +class IncorrectFileHttpException extends UnprocessableEntityHttpException +{ +} diff --git a/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolver.php b/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolver.php new file mode 100644 index 00000000..559cd529 --- /dev/null +++ b/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolver.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Symfony\HttpKernel; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Zenstruck\Filesystem\Node\File\PendingFile; + +/** + * @author Jakub Caban + * + * @internal + */ +if (\interface_exists(ValueResolverInterface::class)) { + class PendingFileValueResolver implements ValueResolverInterface + { + use PendingFileValueResolverTrait { + resolve as resolveArgument; + } + + /** + * @return iterable + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + if (!RequestFilesExtractor::supports($argument)) { + return []; + } + + return $this->resolveArgument($request, $argument); + } + } +} else { + class PendingFileValueResolver implements ArgumentValueResolverInterface + { + use PendingFileValueResolverTrait; + + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return RequestFilesExtractor::supports($argument); + } + } +} diff --git a/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolverTrait.php b/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolverTrait.php new file mode 100644 index 00000000..75693748 --- /dev/null +++ b/src/Filesystem/Symfony/HttpKernel/PendingFileValueResolverTrait.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Symfony\HttpKernel; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; +use Zenstruck\Filesystem; +use Zenstruck\Filesystem\Attribute\PendingUploadedFile; +use Zenstruck\Filesystem\Attribute\UploadedFile; +use Zenstruck\Filesystem\FilesystemRegistry; +use Zenstruck\Filesystem\Node; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Node\File\PendingFile; +use Zenstruck\Filesystem\Node\PathGenerator; +use Zenstruck\Filesystem\Symfony\Exception\IncorrectFileHttpException; + +/** + * @author Jakub Caban + * + * @internal + */ +trait PendingFileValueResolverTrait +{ + /** + * @param ServiceProviderInterface $locator + */ + public function __construct(private ServiceProviderInterface $locator) + { + } + + /** + * @return iterable + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $attribute = PendingUploadedFile::forArgument($argument); + + $files = $this->extractor()->extractFilesFromRequest( + $request, + (string) $attribute->path, + 'array' === $argument->getType(), + (bool) $attribute->image, + ); + + if (!$files) { + return [$files]; + } + + if ($attribute->constraints) { + $errors = $this->validator()->validate( + $files, + $attribute->constraints + ); + + if (\count($errors)) { + \assert($errors instanceof ConstraintViolationList); + + throw new IncorrectFileHttpException((string) $errors); + } + } + + if ($attribute instanceof UploadedFile) { + if (\is_array($files)) { + $files = \array_map( + fn(PendingFile $file) => $this->saveFile($attribute, $file), + $files + ); + } else { + $files = $this->saveFile($attribute, $files); + } + } + + return [$files]; + } + + private function saveFile(UploadedFile $uploadedFile, PendingFile $file): File + { + $path = $this->generatePath($uploadedFile, $file); + $file = $this->filesystem($uploadedFile->filesystem) + ->write($path, $file) + ; + + if ($uploadedFile->image) { + return $file->ensureImage(); + } + + return $file; + } + + private function extractor(): RequestFilesExtractor + { + return $this->locator->get(RequestFilesExtractor::class); + } + + private function filesystem(string $filesystem): Filesystem + { + return $this->locator->get(FilesystemRegistry::class)->get($filesystem); + } + + private function generatePath(UploadedFile $uploadedFile, Node $node): string + { + return $this->locator->get(PathGenerator::class)->generate( + $uploadedFile->namer, + $node + ); + } + + private function validator(): ValidatorInterface + { + return $this->locator->get(ValidatorInterface::class); + } +} diff --git a/src/Filesystem/Symfony/HttpKernel/RequestFilesExtractor.php b/src/Filesystem/Symfony/HttpKernel/RequestFilesExtractor.php new file mode 100644 index 00000000..56c9d5f3 --- /dev/null +++ b/src/Filesystem/Symfony/HttpKernel/RequestFilesExtractor.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem\Symfony\HttpKernel; + +use League\Flysystem\FilesystemException; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Zenstruck\Filesystem\Attribute\PendingUploadedFile; +use Zenstruck\Filesystem\Exception\NodeTypeMismatch; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Node\File\Image\PendingImage; +use Zenstruck\Filesystem\Node\File\PendingFile; + +/** + * @author Jakub Caban + * + * @internal + */ +class RequestFilesExtractor +{ + public function __construct(private PropertyAccessor $propertyAccessor) + { + } + + public function extractFilesFromRequest( + Request $request, + string $path, + bool $returnArray = false, + bool $returnImage = false, + ): PendingFile|array|null { + $path = $this->canonizePath($path); + + $files = $this->propertyAccessor->getValue($request->files->all(), $path); + + if ($returnArray) { + if (!$files) { + return []; + } + + if (!\is_array($files)) { + $files = [$files]; + } + + return \array_map( + static fn(UploadedFile $file) => $returnImage ? new PendingImage($file) : new PendingFile($file), + $files + ); + } + + if (\is_array($files)) { + throw new \LogicException(\sprintf('Could not extract files from request for "%s" path: expecting a single file, got %d files.', $path, \count($files))); + } + + if (!$files) { + return null; + } + + $file = new PendingFile($files); + + if ($returnImage) { + try { + return $file->ensureImage(); + } catch (NodeTypeMismatch|FilesystemException) { + // Incorrect images should be skipped + + return null; + } + } + + return $file; + } + + public static function supports(ArgumentMetadata $argument): bool + { + $attributes = $argument->getAttributes(PendingUploadedFile::class, ArgumentMetadata::IS_INSTANCEOF); + + if (empty($attributes)) { + $type = $argument->getType(); + + return $type && \is_a($type, File::class, true); + } + + return true; + } + + /** + * Convert HTML paths to PropertyAccessor compatible. + * Examples: "data[file]" -> "[data][file]", "files[]" -> "[files]". + */ + private function canonizePath(string $path): string + { + $path = \preg_replace( + '/\[]$/', + '', + $path + ); + // Correct arguments passed to preg_replace guarantee string return + \assert(\is_string($path)); + + if ('[' !== $path[0]) { + $path = \preg_replace( + '/^([^[]+)/', + '[$1]', + $path + ); + // Correct arguments passed to preg_replace guarantee string return + \assert(\is_string($path)); + } + + return $path; + } +} diff --git a/tests/Filesystem/Symfony/HttpKernel/PendingDocumentValueResolverTest.php b/tests/Filesystem/Symfony/HttpKernel/PendingDocumentValueResolverTest.php new file mode 100644 index 00000000..5ebc9db5 --- /dev/null +++ b/tests/Filesystem/Symfony/HttpKernel/PendingDocumentValueResolverTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Tests\Filesystem\Symfony\HttpKernel; + +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; + +/** + * @author Jakub Caban + */ +class PendingDocumentValueResolverTest extends WebTestCase +{ + /** + * @test + */ + public function do_nothing_on_wrong_type(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'no-injection', + files: ['file' => self::uploadedFile()] + ); + + self::assertSame('0', $client->getResponse()->getContent()); + } + + /** + * @test + */ + public function inject_on_typed_argument(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'single-file', + ); + + self::assertSame('', $client->getResponse()->getContent()); + + $client->request( + 'GET', + 'single-file', + files: ['file' => self::uploadedFile()] + ); + + self::assertSame("some content\n", $client->getResponse()->getContent()); + + $client->request( + 'GET', + 'single-image', + files: ['image' => self::uploadedImage()] + ); + + self::assertSame('563', $client->getResponse()->getContent()); + } + + /** + * @test + */ + public function inject_stored(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'single-stored-file', + files: ['file' => self::uploadedFile()] + ); + + self::assertSame( + "public://eb9c2bf0eb63f3a7bc0ea37ef18aeba5/test.txt:some content\n", + $client->getResponse()->getContent() + ); + } + + /** + * @test + */ + public function inject_on_typed_argument_with_path(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'single-file-with-path', + files: ['data' => ['file' => self::uploadedFile()]] + ); + + self::assertSame("some content\n", $client->getResponse()->getContent()); + } + + /** + * @test + */ + public function inject_array_on_argument_with_attribute(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'multiple-files' + ); + + self::assertSame('0', $client->getResponse()->getContent()); + + $client->request( + 'GET', + 'multiple-files', + files: ['files' => self::uploadedFile()] + ); + + self::assertSame('1', $client->getResponse()->getContent()); + + $client->request( + 'GET', + 'multiple-images', + files: ['images' => self::uploadedImage()] + ); + + self::assertSame('1', $client->getResponse()->getContent()); + } + + /** + * @test + */ + public function inject_array_on_argument_with_attribute_and_path(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'multiple-files-with-path' + ); + + self::assertSame('0', $client->getResponse()->getContent()); + + $client->request( + 'GET', + 'multiple-files-with-path', + files: ['data' => ['files' => self::uploadedFile()]] + ); + + self::assertSame('1', $client->getResponse()->getContent()); + } + + /** + * @test + */ + public function returns_exception_for_invalid_file(): void + { + $client = self::createClient(); + + $client->request( + 'GET', + 'single-image', + files: ['image' => self::uploadedFile()] + ); + $response = $client->getResponse(); + self::assertSame(422, $response->getStatusCode()); + + if (\PHP_VERSION_ID >= 80100) { + $client->request( + 'GET', + 'validated-file', + files: ['file' => self::uploadedFile()] + ); + $response = $client->getResponse(); + + self::assertSame(422, $response->getStatusCode()); + } + } + + private static function uploadedFile(): UploadedFile + { + return new UploadedFile( + fixture('textfile.txt'), + 'test.txt', + test: true + ); + } + + private static function uploadedImage(): UploadedFile + { + return new UploadedFile( + fixture('symfony.png'), + 'symfony.png', + test: true + ); + } +} diff --git a/tests/Filesystem/Symfony/HttpKernel/RequestFilesExtractorTest.php b/tests/Filesystem/Symfony/HttpKernel/RequestFilesExtractorTest.php new file mode 100644 index 00000000..7be66f69 --- /dev/null +++ b/tests/Filesystem/Symfony/HttpKernel/RequestFilesExtractorTest.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Tests\Filesystem\Symfony\HttpKernel; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Zenstruck\Filesystem\Node\File\Image\PendingImage; +use Zenstruck\Filesystem\Node\File\PendingFile; +use Zenstruck\Filesystem\Symfony\HttpKernel\RequestFilesExtractor; + +/** + * @author Jakub Caban + */ +class RequestFilesExtractorTest extends TestCase +{ + /** + * @test + */ + public function returns_null_for_empty_request(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + + self::assertNull( + $extractor->extractFilesFromRequest($request, 'file') + ); + } + + /** + * @test + */ + public function returns_null_for_empty_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('upload', self::uploadedFile()); + + self::assertNull( + $extractor->extractFilesFromRequest($request, 'file') + ); + } + + /** + * @test + */ + public function returns_file_for_correct_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('file', self::uploadedFile()); + + $document = $extractor->extractFilesFromRequest($request, 'file'); + self::assertNotNull($document); + self::assertInstanceOf(PendingFile::class, $document); + self::assertSame("some content\n", $document->contents()); + } + + /** + * @test + */ + public function returns_image_for_correct_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('file', self::uploadedImage()); + + $document = $extractor->extractFilesFromRequest($request, 'file', returnImage: true); + self::assertNotNull($document); + self::assertInstanceOf(PendingImage::class, $document); + } + + /** + * @test + */ + public function returns_file_for_correct_nested_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('data', ['file' => self::uploadedFile()]); + + $document = $extractor->extractFilesFromRequest($request, 'data[file]'); + self::assertNotNull($document); + self::assertInstanceOf(PendingFile::class, $document); + self::assertSame("some content\n", $document->contents()); + } + + /** + * @test + */ + public function throws_for_single_file_with_array_of_files(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('upload', [self::uploadedFile()]); + + $this->expectException(\LogicException::class); + $extractor->extractFilesFromRequest($request, 'upload'); + } + + /** + * @test + */ + public function returns_empty_array_for_empty_request(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + + self::assertSame( + [], + $extractor->extractFilesFromRequest($request, 'file', true) + ); + } + + /** + * @test + */ + public function returns_empty_array_for_empty_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('upload', [self::uploadedFile()]); + + self::assertSame( + [], + $extractor->extractFilesFromRequest($request, 'file', true) + ); + } + + /** + * @test + */ + public function returns_array_for_single_file_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('file', self::uploadedFile()); + + $documents = $extractor->extractFilesFromRequest($request, 'file', true); + self::assertIsArray($documents); + self::assertCount(1, $documents); + self::assertInstanceOf(PendingFile::class, $documents[0]); + self::assertSame("some content\n", $documents[0]->contents()); + } + + /** + * @test + */ + public function returns_array_for_multiple_files_path(): void + { + $extractor = self::extractor(); + + $request = Request::create(''); + $request->files->set('file', [self::uploadedFile(), self::uploadedFile()]); + + $documents = $extractor->extractFilesFromRequest($request, 'file', true); + self::assertIsArray($documents); + self::assertCount(2, $documents); + self::assertInstanceOf(PendingFile::class, $documents[0]); + self::assertSame("some content\n", $documents[0]->contents()); + } + + private static function uploadedFile(): UploadedFile + { + return new UploadedFile( + fixture('textfile.txt'), + 'test.txt', + test: true + ); + } + + private static function uploadedImage(): UploadedFile + { + return new UploadedFile( + fixture('symfony.png'), + 'symfony.png', + test: true + ); + } + + private static function extractor(): RequestFilesExtractor + { + return new RequestFilesExtractor( + new PropertyAccessor( + PropertyAccessor::DISALLOW_MAGIC_METHODS, + PropertyAccessor::THROW_ON_INVALID_PROPERTY_PATH + ) + ); + } +} diff --git a/tests/Fixtures/Controller/ArgumentResolverController.php b/tests/Fixtures/Controller/ArgumentResolverController.php new file mode 100644 index 00000000..665677b0 --- /dev/null +++ b/tests/Fixtures/Controller/ArgumentResolverController.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Tests\Fixtures\Controller; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Zenstruck\Filesystem\Attribute\PendingUploadedFile; +use Zenstruck\Filesystem\Attribute\UploadedFile; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Node\File\Image; + +/** + * @author Jakub Caban + */ +class ArgumentResolverController +{ + #[Route('/multiple-files', name: 'multiple-files')] + public function multipleFiles( + #[PendingUploadedFile] + array $files + ): Response { + return new Response((string) \count($files)); + } + + #[Route('/multiple-images', name: 'multiple-images')] + public function multipleImages( + #[PendingUploadedFile(image: true)] + array $images + ): Response { + return new Response((string) \count($images)); + } + + #[Route('/multiple-files-with-path', name: 'multiple-files-with-path')] + public function multipleFilesWithPath( + #[PendingUploadedFile('data[files]')] + array $files + ): Response { + return new Response((string) \count($files)); + } + + #[Route('/no-injection', name: 'no-injection')] + public function noInjection(array $file = []): Response + { + return new Response((string) \count($file)); + } + + #[Route('/single-file', name: 'single-file')] + public function singleFile(?File $file): Response + { + return new Response($file?->contents() ?? ''); + } + + #[Route('/single-stored-file', name: 'single-stored-file')] + public function singleStoredFile( + #[UploadedFile('public')] + File $file + ): Response { + return new Response( + \sprintf( + '%s:%s', + $file->dsn(), + $file->contents() + ) + ); + } + + #[Route('/single-image', name: 'single-image')] + public function singleImage(Image $image): Response + { + return new Response((string) $image->dimensions()->width()); + } + + #[Route('/single-file-with-path', name: 'single-file-with-path')] + public function singleFileWithPath( + #[PendingUploadedFile('data[file]')] + ?File $file + ): Response { + return new Response($file?->contents() ?? ''); + } +} diff --git a/tests/Fixtures/Controller/ValidatedArgumentResolverController.php b/tests/Fixtures/Controller/ValidatedArgumentResolverController.php new file mode 100644 index 00000000..83a3dffe --- /dev/null +++ b/tests/Fixtures/Controller/ValidatedArgumentResolverController.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Tests\Fixtures\Controller; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Zenstruck\Filesystem\Attribute\PendingUploadedFile; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Symfony\Validator\PendingFileConstraint; + +/** + * @author Jakub Caban + */ +class ValidatedArgumentResolverController +{ + #[Route('/validated-file', name: 'validated-file')] + public function validatedFile( + #[PendingUploadedFile( + constraints: [new PendingFileConstraint(mimeTypes: ['application/pdf'])] + )] + File $file + ): Response { + return new Response($file->contents()); + } +} diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php index 4b3bbad0..f857c719 100644 --- a/tests/Fixtures/TestKernel.php +++ b/tests/Fixtures/TestKernel.php @@ -13,6 +13,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use League\Glide\Urls\UrlBuilder; +use Psr\Log\NullLogger; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\TwigBundle\TwigBundle; @@ -168,6 +169,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ->setAutowired(true) ->setAutoconfigured(true) ; + $c->register('logger', NullLogger::class); // disable logging } protected function configureRoutes(RoutingConfigurator $routes): void @@ -184,5 +186,10 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->add('private_public', '/private/{path}') ->requirements(['path' => '.+']) ; + $routes->import(__DIR__.'/Controller/ArgumentResolverController.php', 'annotation'); + + if (\PHP_VERSION_ID >= 80100) { + $routes->import(__DIR__.'/Controller/ValidatedArgumentResolverController.php', 'annotation'); + } } }