Skip to content

Commit

Permalink
feat(symfony): add value resolver (#95)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
Lustmored and kbond authored Apr 7, 2023
1 parent 827a018 commit 5de2c9a
Show file tree
Hide file tree
Showing 13 changed files with 991 additions and 1 deletion.
70 changes: 70 additions & 0 deletions src/Filesystem/Attribute/PendingUploadedFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*
* @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;
}
}
38 changes: 38 additions & 0 deletions src/Filesystem/Attribute/UploadedFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*
* @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}');
}
}
2 changes: 1 addition & 1 deletion src/Filesystem/Glide/GlideTransformUrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
21 changes: 21 additions & 0 deletions src/Filesystem/Symfony/Exception/IncorrectFileHttpException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*/
class IncorrectFileHttpException extends UnprocessableEntityHttpException
{
}
54 changes: 54 additions & 0 deletions src/Filesystem/Symfony/HttpKernel/PendingFileValueResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*
* @internal
*/
if (\interface_exists(ValueResolverInterface::class)) {
class PendingFileValueResolver implements ValueResolverInterface
{
use PendingFileValueResolverTrait {
resolve as resolveArgument;
}

/**
* @return iterable<PendingFile|array|null>
*/
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);
}
}
}
124 changes: 124 additions & 0 deletions src/Filesystem/Symfony/HttpKernel/PendingFileValueResolverTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*
* @internal
*/
trait PendingFileValueResolverTrait
{
/**
* @param ServiceProviderInterface<mixed> $locator
*/
public function __construct(private ServiceProviderInterface $locator)
{
}

/**
* @return iterable<File|array|null>
*/
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);
}
}
Loading

0 comments on commit 5de2c9a

Please sign in to comment.