Skip to content

Commit 5de2c9a

Browse files
Lustmoredkbond
andauthored
feat(symfony): add value resolver (#95)
* Import value resolver from document-library * Refactor FilesExtractor to handle PendingFile subclasses in supports() * Handle PendingImage in value resolving * Fix Phpstan warnings and run CS fixer * Add missing functional tests * Allow File/Image typehint for value resolver * Simplify PendingFileValueResolver by introducing a common trait * Add simple image injection functional tests * Utilize UploadedFile::forArgument() for a centralized value resolver configuration * Extend UploadedFile to constraints and errorStatus * Add file validation logic to pendingfileValueResolver * Add simple test case for invalid image * Add valueResolver exception tests * CS run * Trailing comma is a no-go in PHP8 :) * Make phpstan happy * Trivial suggestions * Disable logger on tests * Add custom exception for incorrect file * Revert controller loader to annotation for older Sf versions * Hide PHP 8.1+ test under if * Move constraints logic to `forArgument` * UploadedFile::$multiple is no more * Move image to the last position of UploadedFile constructor * Introduce UploadedFile as extender of PendingUploadedFile attribute (non functional yet!) * Handle storing files with #[UploadedFile] attribute * Fix typehint * Update src/Filesystem/Symfony/HttpKernel/RequestFilesExtractor.php Co-authored-by: Kevin Bond <[email protected]> * Move IncorrectFileHttpException * Remove phpstan ignore * Add phpstan-ignore-line and add @readonly to attributes * CS fixer run * Fix tests * code style again * Add pointless docblock to silence phpstan * Apply suggestions from code review Co-authored-by: Kevin Bond <[email protected]> * Mark PendingUploadedFile constructor as consistent for phpstan --------- Co-authored-by: Kevin Bond <[email protected]>
1 parent 827a018 commit 5de2c9a

13 files changed

+991
-1
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem\Attribute;
13+
14+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
15+
use Symfony\Component\Validator\Constraints\All;
16+
use Zenstruck\Filesystem\Node\File;
17+
use Zenstruck\Filesystem\Node\File\Image;
18+
use Zenstruck\Filesystem\Symfony\Validator\PendingImageConstraint;
19+
20+
/**
21+
* @author Jakub Caban <[email protected]>
22+
*
23+
* @phpstan-consistent-constructor
24+
* @readonly
25+
*/
26+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
27+
class PendingUploadedFile
28+
{
29+
public function __construct(
30+
public ?string $path = null,
31+
public ?array $constraints = null,
32+
public ?bool $image = null,
33+
) {
34+
}
35+
36+
/**
37+
* @internal
38+
*/
39+
public static function forArgument(ArgumentMetadata $argument): self
40+
{
41+
$attributes = $argument->getAttributes(self::class, ArgumentMetadata::IS_INSTANCEOF);
42+
43+
if (!empty($attributes)) {
44+
$attribute = $attributes[0];
45+
\assert($attribute instanceof self);
46+
} else {
47+
$attribute = new static();
48+
}
49+
50+
$attribute->path ??= $argument->getName();
51+
52+
$attribute->image ??= \is_a(
53+
$argument->getType() ?? File::class,
54+
Image::class,
55+
true
56+
);
57+
58+
if (null === $attribute->constraints && $attribute->image) {
59+
if ('array' === $argument->getType()) {
60+
$attribute->constraints = [
61+
new All([new PendingImageConstraint()]),
62+
];
63+
} else {
64+
$attribute->constraints = [new PendingImageConstraint()];
65+
}
66+
}
67+
68+
return $attribute;
69+
}
70+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem\Attribute;
13+
14+
use Zenstruck\Filesystem\Node\Path\Expression;
15+
use Zenstruck\Filesystem\Node\Path\Namer;
16+
17+
/**
18+
* @author Jakub Caban <[email protected]>
19+
*
20+
* @readonly
21+
*/
22+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
23+
final class UploadedFile extends PendingUploadedFile
24+
{
25+
public string|Namer $namer;
26+
27+
public function __construct(
28+
public string $filesystem,
29+
string|Namer|null $namer = null,
30+
?string $path = null,
31+
?array $constraints = null,
32+
?bool $image = null,
33+
) {
34+
parent::__construct($path, $constraints, $image);
35+
36+
$this->namer = $namer ?? new Expression('{checksum}/{name}{ext}');
37+
}
38+
}

src/Filesystem/Glide/GlideTransformUrlGenerator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function transformUrl(string $path, array|string $filter, Config $config)
2828
{
2929
$filter = match (true) { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/8937
3030
\is_string($filter) => ['p' => $filter], // is glide "preset"
31-
\is_array($filter) && !array_is_list($filter) => $filter, // is standard glide parameters
31+
\is_array($filter) && !\array_is_list($filter) => $filter, // is standard glide parameters
3232
\is_array($filter) => ['p' => \implode(',', $filter)], // is array of "presets"
3333
};
3434

src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
2626
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
2727
use Symfony\Component\String\Slugger\SluggerInterface;
28+
use Symfony\Component\Validator\Validator\ValidatorInterface;
2829
use Symfony\Contracts\Translation\LocaleAwareInterface;
2930
use Zenstruck\Filesystem;
3031
use Zenstruck\Filesystem\Doctrine\EventListener\NodeLifecycleListener;
@@ -45,6 +46,8 @@
4546
use Zenstruck\Filesystem\Symfony\Command\FilesystemPurgeCommand;
4647
use Zenstruck\Filesystem\Symfony\Form\PendingFileType;
4748
use Zenstruck\Filesystem\Symfony\HttpKernel\FilesystemDataCollector;
49+
use Zenstruck\Filesystem\Symfony\HttpKernel\PendingFileValueResolver;
50+
use Zenstruck\Filesystem\Symfony\HttpKernel\RequestFilesExtractor;
4851
use Zenstruck\Filesystem\Symfony\Routing\RoutePublicUrlGenerator;
4952
use Zenstruck\Filesystem\Symfony\Routing\RouteTemporaryUrlGenerator;
5053
use Zenstruck\Filesystem\Symfony\Routing\RouteTransformUrlGenerator;
@@ -155,6 +158,22 @@ private function registerDoctrine(ContainerBuilder $container, array $config): v
155158
$listener->addTag('doctrine.event_listener', ['event' => 'postRemove']);
156159
}
157160

161+
// value resolver
162+
$container->register('.zenstruck_document.value_resolver.request_files_extractor', RequestFilesExtractor::class)
163+
->addArgument(new Reference('property_accessor'))
164+
;
165+
$container->register('.zenstruck_document.value_resolver.pending_document', PendingFileValueResolver::class)
166+
->addTag('controller.argument_value_resolver', ['priority' => 110])
167+
->addArgument(
168+
new ServiceLocatorArgument([
169+
FilesystemRegistry::class => new Reference(FilesystemRegistry::class),
170+
PathGenerator::class => new Reference(PathGenerator::class),
171+
RequestFilesExtractor::class => new Reference('.zenstruck_document.value_resolver.request_files_extractor'),
172+
ValidatorInterface::class => new Reference(ValidatorInterface::class),
173+
])
174+
)
175+
;
176+
158177
if (isset($container->getParameter('kernel.bundles')['TwigBundle'])) {
159178
$container->register('.zenstruck_filesystem.doctrine.twig_extension', MappingManagerExtension::class)
160179
->addTag('twig.extension')
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem\Symfony\Exception;
13+
14+
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
15+
16+
/**
17+
* @author Jakub Caban <[email protected]>
18+
*/
19+
class IncorrectFileHttpException extends UnprocessableEntityHttpException
20+
{
21+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem\Symfony\HttpKernel;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
16+
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
17+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
18+
use Zenstruck\Filesystem\Node\File\PendingFile;
19+
20+
/**
21+
* @author Jakub Caban <[email protected]>
22+
*
23+
* @internal
24+
*/
25+
if (\interface_exists(ValueResolverInterface::class)) {
26+
class PendingFileValueResolver implements ValueResolverInterface
27+
{
28+
use PendingFileValueResolverTrait {
29+
resolve as resolveArgument;
30+
}
31+
32+
/**
33+
* @return iterable<PendingFile|array|null>
34+
*/
35+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
36+
{
37+
if (!RequestFilesExtractor::supports($argument)) {
38+
return [];
39+
}
40+
41+
return $this->resolveArgument($request, $argument);
42+
}
43+
}
44+
} else {
45+
class PendingFileValueResolver implements ArgumentValueResolverInterface
46+
{
47+
use PendingFileValueResolverTrait;
48+
49+
public function supports(Request $request, ArgumentMetadata $argument): bool
50+
{
51+
return RequestFilesExtractor::supports($argument);
52+
}
53+
}
54+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem\Symfony\HttpKernel;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
16+
use Symfony\Component\Validator\ConstraintViolationList;
17+
use Symfony\Component\Validator\Validator\ValidatorInterface;
18+
use Symfony\Contracts\Service\ServiceProviderInterface;
19+
use Zenstruck\Filesystem;
20+
use Zenstruck\Filesystem\Attribute\PendingUploadedFile;
21+
use Zenstruck\Filesystem\Attribute\UploadedFile;
22+
use Zenstruck\Filesystem\FilesystemRegistry;
23+
use Zenstruck\Filesystem\Node;
24+
use Zenstruck\Filesystem\Node\File;
25+
use Zenstruck\Filesystem\Node\File\PendingFile;
26+
use Zenstruck\Filesystem\Node\PathGenerator;
27+
use Zenstruck\Filesystem\Symfony\Exception\IncorrectFileHttpException;
28+
29+
/**
30+
* @author Jakub Caban <[email protected]>
31+
*
32+
* @internal
33+
*/
34+
trait PendingFileValueResolverTrait
35+
{
36+
/**
37+
* @param ServiceProviderInterface<mixed> $locator
38+
*/
39+
public function __construct(private ServiceProviderInterface $locator)
40+
{
41+
}
42+
43+
/**
44+
* @return iterable<File|array|null>
45+
*/
46+
public function resolve(Request $request, ArgumentMetadata $argument): iterable
47+
{
48+
$attribute = PendingUploadedFile::forArgument($argument);
49+
50+
$files = $this->extractor()->extractFilesFromRequest(
51+
$request,
52+
(string) $attribute->path,
53+
'array' === $argument->getType(),
54+
(bool) $attribute->image,
55+
);
56+
57+
if (!$files) {
58+
return [$files];
59+
}
60+
61+
if ($attribute->constraints) {
62+
$errors = $this->validator()->validate(
63+
$files,
64+
$attribute->constraints
65+
);
66+
67+
if (\count($errors)) {
68+
\assert($errors instanceof ConstraintViolationList);
69+
70+
throw new IncorrectFileHttpException((string) $errors);
71+
}
72+
}
73+
74+
if ($attribute instanceof UploadedFile) {
75+
if (\is_array($files)) {
76+
$files = \array_map(
77+
fn(PendingFile $file) => $this->saveFile($attribute, $file),
78+
$files
79+
);
80+
} else {
81+
$files = $this->saveFile($attribute, $files);
82+
}
83+
}
84+
85+
return [$files];
86+
}
87+
88+
private function saveFile(UploadedFile $uploadedFile, PendingFile $file): File
89+
{
90+
$path = $this->generatePath($uploadedFile, $file);
91+
$file = $this->filesystem($uploadedFile->filesystem)
92+
->write($path, $file)
93+
;
94+
95+
if ($uploadedFile->image) {
96+
return $file->ensureImage();
97+
}
98+
99+
return $file;
100+
}
101+
102+
private function extractor(): RequestFilesExtractor
103+
{
104+
return $this->locator->get(RequestFilesExtractor::class);
105+
}
106+
107+
private function filesystem(string $filesystem): Filesystem
108+
{
109+
return $this->locator->get(FilesystemRegistry::class)->get($filesystem);
110+
}
111+
112+
private function generatePath(UploadedFile $uploadedFile, Node $node): string
113+
{
114+
return $this->locator->get(PathGenerator::class)->generate(
115+
$uploadedFile->namer,
116+
$node
117+
);
118+
}
119+
120+
private function validator(): ValidatorInterface
121+
{
122+
return $this->locator->get(ValidatorInterface::class);
123+
}
124+
}

0 commit comments

Comments
 (0)