├── .gitattributes ├── .gitignore ├── phpstan.neon ├── .editorconfig ├── .codecov.yml ├── src ├── Document │ ├── Namer.php │ ├── Attribute │ │ └── UploadedFile.php │ ├── Namer │ │ ├── Expression.php │ │ ├── SlugifyNamer.php │ │ ├── ChecksumNamer.php │ │ ├── BaseNamer.php │ │ ├── MultiNamer.php │ │ └── ExpressionNamer.php │ ├── Library.php │ ├── Library │ │ ├── Bridge │ │ │ ├── Symfony │ │ │ │ ├── Validator │ │ │ │ │ ├── DocumentConstraint.php │ │ │ │ │ └── DocumentValidator.php │ │ │ │ ├── Serializer │ │ │ │ │ ├── LazyDocumentNormalizer.php │ │ │ │ │ └── DocumentNormalizer.php │ │ │ │ ├── HttpKernel │ │ │ │ │ ├── DoctrineMappingProviderCacheWarmer.php │ │ │ │ │ ├── RequestFilesExtractor.php │ │ │ │ │ └── PendingDocumentValueResolver.php │ │ │ │ ├── ZenstruckDocumentLibraryBundle.php │ │ │ │ ├── DependencyInjection │ │ │ │ │ ├── Configuration.php │ │ │ │ │ └── ZenstruckDocumentLibraryExtension.php │ │ │ │ ├── Form │ │ │ │ │ ├── PendingDocumentType.php │ │ │ │ │ └── DocumentType.php │ │ │ │ └── HttpFoundation │ │ │ │ │ └── DocumentResponse.php │ │ │ └── Doctrine │ │ │ │ ├── Persistence │ │ │ │ ├── MappingProvider.php │ │ │ │ ├── EventListener │ │ │ │ │ ├── LazyDocumentLifecycleSubscriber.php │ │ │ │ │ └── DocumentLifecycleSubscriber.php │ │ │ │ ├── Mapping │ │ │ │ │ ├── CacheMappingProvider.php │ │ │ │ │ └── ManagerRegistryMappingProvider.php │ │ │ │ ├── ObjectReflector.php │ │ │ │ └── Mapping.php │ │ │ │ └── DBAL │ │ │ │ └── Types │ │ │ │ ├── DocumentStringType.php │ │ │ │ └── DocumentJsonType.php │ │ ├── LazyLibrary.php │ │ └── FlysystemLibrary.php │ ├── LibraryRegistry.php │ ├── NullDocument.php │ ├── SerializableDocument.php │ ├── PendingDocument.php │ ├── LazyDocument.php │ └── FlysystemDocument.php └── Document.php ├── LICENSE ├── phpunit.xml.dist ├── composer.json ├── .php-cs-fixer.dist.php └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | phpunit.dist.xml export-ignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /var/ 6 | /.php-cs-fixer.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | ignoreErrors: 6 | - '#no value type specified in iterable type array#' 7 | 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /src/Document/Namer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface Namer 11 | { 12 | public function generateName(Document $document, array $context = []): string; 13 | } 14 | -------------------------------------------------------------------------------- /src/Document/Attribute/UploadedFile.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | #[\Attribute(\Attribute::TARGET_PARAMETER)] 9 | final class UploadedFile 10 | { 11 | public function __construct( 12 | public ?string $path = null 13 | ) { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Document/Namer/Expression.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | final class Expression implements \Stringable 9 | { 10 | public function __construct(private string $value) 11 | { 12 | } 13 | 14 | public function __toString(): string 15 | { 16 | return "expression:{$this->value}"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Document/Namer/SlugifyNamer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class SlugifyNamer extends BaseNamer 11 | { 12 | protected function generate(Document $document, array $context = []): string 13 | { 14 | return $this->slugify($document->nameWithoutExtension()).self::extensionWithDot($document); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Document/Library.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface Library 11 | { 12 | public function open(string $path): Document; 13 | 14 | public function has(string $path): bool; 15 | 16 | public function store(string $path, Document|\SplFileInfo|string $document, array $config = []): Document; 17 | 18 | public function delete(string $path): static; 19 | } 20 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Validator/DocumentConstraint.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 15 | final class DocumentConstraint extends File 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/MappingProvider.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface MappingProvider 9 | { 10 | /** 11 | * @param class-string $class 12 | * 13 | * @return array 14 | */ 15 | public function get(string $class): array; 16 | 17 | /** 18 | * @return array> 19 | */ 20 | public function all(): array; 21 | } 22 | -------------------------------------------------------------------------------- /src/Document/Namer/ChecksumNamer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ChecksumNamer extends BaseNamer 11 | { 12 | protected function generate(Document $document, array $context = []): string 13 | { 14 | return 15 | self::checksum($document, $context['alg'] ?? $context['algorithm'] ?? null, $context['length'] ?? null). 16 | self::extensionWithDot($document) 17 | ; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Serializer/LazyDocumentNormalizer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class LazyDocumentNormalizer extends DocumentNormalizer 13 | { 14 | public function __construct(private ContainerInterface $container) 15 | { 16 | } 17 | 18 | protected function registry(): LibraryRegistry 19 | { 20 | return $this->container->get(LibraryRegistry::class); 21 | } 22 | 23 | protected function namer(): Namer 24 | { 25 | return $this->container->get(Namer::class); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/HttpKernel/DoctrineMappingProviderCacheWarmer.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | * @internal 12 | */ 13 | final class DoctrineMappingProviderCacheWarmer implements CacheWarmerInterface 14 | { 15 | public function __construct(private CacheMappingProvider $provider) 16 | { 17 | } 18 | 19 | public function isOptional(): bool 20 | { 21 | return false; 22 | } 23 | 24 | public function warmUp(string $cacheDir): array 25 | { 26 | $this->provider->warm(); 27 | 28 | return []; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/ZenstruckDocumentLibraryBundle.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class ZenstruckDocumentLibraryBundle extends Bundle 14 | { 15 | public function boot(): void 16 | { 17 | parent::boot(); 18 | 19 | if (!\class_exists(Type::class)) { 20 | return; 21 | } 22 | 23 | foreach ([DocumentJsonType::class, DocumentStringType::class] as $type) { 24 | if (!Type::hasType($type::NAME)) { 25 | Type::addType($type::NAME, $type); 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/DBAL/Types/DocumentStringType.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class DocumentStringType extends StringType 14 | { 15 | public const NAME = 'document_string'; 16 | 17 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string 18 | { 19 | return $value instanceof Document ? $value->path() : null; 20 | } 21 | 22 | public function convertToPHPValue($value, AbstractPlatform $platform): ?Document 23 | { 24 | return \is_string($value) ? new LazyDocument($value) : null; 25 | } 26 | 27 | public function getName(): string 28 | { 29 | return self::NAME; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Document/LibraryRegistry.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class LibraryRegistry 12 | { 13 | /** @var array */ 14 | private array $lazyLibraries = []; 15 | 16 | /** 17 | * @param array $libraries 18 | */ 19 | public function __construct(private ContainerInterface|array $libraries) 20 | { 21 | } 22 | 23 | public function get(string $name): Library 24 | { 25 | return $this->lazyLibraries[$name] ??= new LazyLibrary(function() use ($name) { 26 | if ($this->libraries instanceof ContainerInterface) { 27 | return $this->libraries->get($name); 28 | } 29 | 30 | return $this->libraries[$name] ?? throw new \InvalidArgumentException(); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | * @internal 12 | */ 13 | final class Configuration implements ConfigurationInterface 14 | { 15 | public function getConfigTreeBuilder(): TreeBuilder 16 | { 17 | $treeBuilder = new TreeBuilder('zenstruck_document_library'); 18 | 19 | $treeBuilder->getRootNode() // @phpstan-ignore-line 20 | ->children() 21 | ->arrayNode('libraries') 22 | ->info('Library configurations') 23 | ->useAttributeAsKey('name') 24 | ->scalarPrototype()->end() 25 | ->end() 26 | ->end() 27 | ; 28 | 29 | return $treeBuilder; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/EventListener/LazyDocumentLifecycleSubscriber.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class LazyDocumentLifecycleSubscriber extends DocumentLifecycleSubscriber 14 | { 15 | public function __construct(private ContainerInterface $container) 16 | { 17 | } 18 | 19 | protected function registry(): LibraryRegistry 20 | { 21 | return $this->container->get(LibraryRegistry::class); 22 | } 23 | 24 | protected function mappingProvider(): MappingProvider 25 | { 26 | return $this->container->get(MappingProvider::class); 27 | } 28 | 29 | protected function namer(): Namer 30 | { 31 | return $this->container->get(Namer::class); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kevin Bond 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 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | tests 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/Mapping/CacheMappingProvider.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class CacheMappingProvider implements MappingProvider 13 | { 14 | /** @var array> */ 15 | private array $memoryCache = []; 16 | 17 | public function __construct(private CacheInterface $cache, private MappingProvider $inner) 18 | { 19 | } 20 | 21 | public function get(string $class): array 22 | { 23 | return $this->memoryCache[$class] ??= $this->cache->get(self::createKey($class), fn() => $this->inner->get($class)); 24 | } 25 | 26 | public function all(): array 27 | { 28 | return $this->inner->all(); 29 | } 30 | 31 | public function warm(): void 32 | { 33 | foreach ($this->all() as $class => $mapping) { 34 | $this->cache->get(self::createKey($class), fn() => $mapping, \INF); 35 | } 36 | } 37 | 38 | /** 39 | * @param class-string $class 40 | */ 41 | private static function createKey(string $class): string 42 | { 43 | return 'zs_doc_map_'.\str_replace('\\', '', $class); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Document.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface Document 9 | { 10 | public function path(): string; 11 | 12 | /** 13 | * Returns the file name (with extension if applicable). 14 | * 15 | * @example If path is "foo/bar/baz.txt", returns "baz.txt" 16 | * @example If path is "foo/bar/baz", returns "baz" 17 | */ 18 | public function name(): string; 19 | 20 | /** 21 | * @example If $path is "foo/bar/baz.txt", returns "baz" 22 | * @example If $path is "foo/bar/baz", returns "baz" 23 | */ 24 | public function nameWithoutExtension(): string; 25 | 26 | public function extension(): string; 27 | 28 | public function lastModified(): int; 29 | 30 | public function size(): int; 31 | 32 | public function checksum(array|string $config = []): string; 33 | 34 | public function contents(): string; 35 | 36 | /** 37 | * @return resource 38 | */ 39 | public function read(); 40 | 41 | public function publicUrl(array $config = []): string; 42 | 43 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string; 44 | 45 | /** 46 | * Check if the document still exists. 47 | */ 48 | public function exists(): bool; 49 | 50 | public function mimeType(): string; 51 | 52 | /** 53 | * Clear any cached metadata. 54 | */ 55 | public function refresh(): static; 56 | } 57 | -------------------------------------------------------------------------------- /src/Document/Library/LazyLibrary.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class LazyLibrary implements Library 12 | { 13 | /** @var Library|callable():Library */ 14 | private $library; 15 | 16 | /** 17 | * @param callable():Library $library 18 | */ 19 | public function __construct(callable $library) 20 | { 21 | $this->library = $library; 22 | } 23 | 24 | public function open(string $path): Document 25 | { 26 | return $this->library()->open($path); 27 | } 28 | 29 | public function has(string $path): bool 30 | { 31 | return $this->library()->has($path); 32 | } 33 | 34 | public function store(string $path, \SplFileInfo|Document|string $document, array $config = []): Document 35 | { 36 | return $this->library()->store($path, $document, $config); 37 | } 38 | 39 | public function delete(string $path): static 40 | { 41 | $this->library()->delete($path); 42 | 43 | return $this; 44 | } 45 | 46 | private function library(): Library 47 | { 48 | if ($this->library instanceof Library) { 49 | return $this->library; 50 | } 51 | 52 | if (\is_callable($this->library)) { 53 | return $this->library = ($this->library)(); 54 | } 55 | 56 | throw new \LogicException('A library has not been properly configured'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Document/Library/FlysystemLibrary.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class FlysystemLibrary implements Library 14 | { 15 | public function __construct(private FilesystemOperator $filesystem) 16 | { 17 | } 18 | 19 | public function open(string $path): Document 20 | { 21 | return new FlysystemDocument($this->filesystem, $path); 22 | } 23 | 24 | public function has(string $path): bool 25 | { 26 | return $this->filesystem->fileExists($path); 27 | } 28 | 29 | public function store(string $path, Document|\SplFileInfo|string $document, array $config = []): Document 30 | { 31 | if (\is_string($document)) { 32 | $this->filesystem->write($path, $document, $config); 33 | 34 | return $this->open($path); 35 | } 36 | 37 | if (false === $stream = $document instanceof Document ? $document->read() : \fopen($document, 'r')) { 38 | throw new \RuntimeException(\sprintf('Unable to read "%s".', $document instanceof Document ? $document->path() : $document)); 39 | } 40 | 41 | $this->filesystem->writeStream($path, $stream, $config); 42 | 43 | \fclose($stream); 44 | 45 | return $this->open($path); 46 | } 47 | 48 | public function delete(string $path): static 49 | { 50 | $this->filesystem->delete($path); 51 | 52 | return $this; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/DBAL/Types/DocumentJsonType.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class DocumentJsonType extends JsonType 16 | { 17 | public const NAME = Document::class; 18 | 19 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string 20 | { 21 | if (null === $value) { 22 | return null; 23 | } 24 | 25 | if (!$value instanceof Document) { 26 | throw ConversionException::conversionFailedInvalidType($value, Document::class, [Document::class, 'null']); 27 | } 28 | 29 | return parent::convertToDatabaseValue( 30 | $value instanceof SerializableDocument ? $value->serialize() : $value->path(), 31 | $platform 32 | ); 33 | } 34 | 35 | public function convertToPHPValue($value, AbstractPlatform $platform): ?Document 36 | { 37 | if (!$value) { 38 | return null; 39 | } 40 | 41 | if (!\is_string($value) && !\is_array($value)) { 42 | throw ConversionException::conversionFailedFormat($value, Document::class, 'string|array|null'); 43 | } 44 | 45 | return new LazyDocument(parent::convertToPHPValue($value, $platform)); 46 | } 47 | 48 | public function getName(): string 49 | { 50 | return self::NAME; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/document-library", 3 | "description": "Document (file) object abstraction using Flysystem.", 4 | "homepage": "https://github.com/zenstruck/document-library", 5 | "type": "library", 6 | "license": "MIT", 7 | "keywords": ["filesystem", "flysystem", "media"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.0", 16 | "league/flysystem": "^3.10.1" 17 | }, 18 | "require-dev": { 19 | "doctrine/doctrine-bundle": "^2.7", 20 | "doctrine/orm": "^2.13", 21 | "league/flysystem-memory": "^3.3", 22 | "phpstan/phpstan": "^1.4", 23 | "phpunit/phpunit": "^9.5.0", 24 | "symfony/browser-kit": "^5.4|^6.0", 25 | "symfony/cache": "^5.4|^6.0", 26 | "symfony/form": "^5.4|^6.0", 27 | "symfony/framework-bundle": "^5.4|^6.0", 28 | "symfony/http-foundation": "^5.4|^6.0", 29 | "symfony/mime": "^5.4|^6.0", 30 | "symfony/phpunit-bridge": "^6.1", 31 | "symfony/serializer": "^5.4|^6.0", 32 | "symfony/validator": "^5.4|^6.0", 33 | "symfony/var-dumper": "^5.4|^6.0", 34 | "zenstruck/foundry": "^1.23" 35 | }, 36 | "config": { 37 | "preferred-install": "dist", 38 | "sort-packages": true 39 | }, 40 | "autoload": { 41 | "psr-4": { "Zenstruck\\": ["src/"] } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { "Zenstruck\\Document\\Library\\Tests\\": ["tests/"] } 45 | }, 46 | "suggest": { 47 | "doctrine/dbal": "To use the Document type", 48 | "symfony/framework-bundle": "To use the provided Symfony bundle" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Document/Namer/BaseNamer.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class BaseNamer implements Namer 14 | { 15 | private const ALPHABET = '123456789abcdefghijkmnopqrstuvwxyz'; 16 | 17 | public function __construct(private ?SluggerInterface $slugger = null, private array $defaultContext = []) 18 | { 19 | } 20 | 21 | final public function generateName(Document $document, array $context = []): string 22 | { 23 | return $this->generate($document, \array_merge($this->defaultContext, $context)); 24 | } 25 | 26 | abstract protected function generate(Document $document, array $context): string; 27 | 28 | final protected static function randomString(int $length = 6): string 29 | { 30 | if (!\class_exists(ByteString::class)) { 31 | /** 32 | * @source https://stackoverflow.com/a/13212994 33 | */ 34 | return \mb_substr(\str_shuffle(\str_repeat(self::ALPHABET, (int) \ceil($length / \mb_strlen(self::ALPHABET)))), 1, $length); 35 | } 36 | 37 | return ByteString::fromRandom($length, self::ALPHABET)->toString(); 38 | } 39 | 40 | final protected static function extensionWithDot(Document $document): string 41 | { 42 | return '' === ($ext = $document->extension()) ? '' : '.'.\mb_strtolower($ext); 43 | } 44 | 45 | final protected static function checksum(Document $document, ?string $algorithm, ?int $length): string 46 | { 47 | $checksum = $document->checksum($algorithm ?? []); 48 | 49 | return $length ? \mb_substr($checksum, 0, $length) : $checksum; 50 | } 51 | 52 | final protected function slugify(string $value): string 53 | { 54 | return $this->slugger ? $this->slugger->slug($value) : \mb_strtolower(\str_replace(' ', '-', $value)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Document/Namer/MultiNamer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class MultiNamer implements Namer 13 | { 14 | private const DEFAULT_NAMER = 'expression'; 15 | 16 | /** @var array */ 17 | private array $defaultNamers = []; 18 | 19 | /** 20 | * @param array $namers 21 | */ 22 | public function __construct(private ContainerInterface|array $namers = [], private array $defaultContext = []) 23 | { 24 | } 25 | 26 | public function generateName(Document $document, array $context = []): string 27 | { 28 | $context = \array_merge($this->defaultContext, $context); 29 | $namer = $context['namer'] ?? self::DEFAULT_NAMER; 30 | 31 | if (\is_callable($namer)) { 32 | return $namer($document, $context); 33 | } 34 | 35 | if (\str_starts_with($namer, 'expression:')) { 36 | $context['expression'] = \mb_substr($namer, 11); 37 | $context['namer'] = $namer = 'expression'; 38 | } 39 | 40 | return $this->get($namer)->generateName($document, $context); 41 | } 42 | 43 | private function get(string $name): Namer 44 | { 45 | if (isset($this->defaultNamers[$name])) { 46 | return $this->defaultNamers[$name]; 47 | } 48 | 49 | if (\is_array($this->namers) && isset($this->namers[$name])) { 50 | return $this->namers[$name]; 51 | } 52 | 53 | if ($this->namers instanceof ContainerInterface && $this->namers->has($name)) { 54 | return $this->namers->get($name); 55 | } 56 | 57 | return $this->defaultNamers[$name] = match ($name) { 58 | 'expression' => new ExpressionNamer(), 59 | 'checksum' => new ChecksumNamer(), 60 | 'slugify' => new SlugifyNamer(), 61 | default => throw new \InvalidArgumentException(\sprintf('Namer "%s" is not registered.', $name)), 62 | }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Form/PendingDocumentType.php: -------------------------------------------------------------------------------- 1 | 17 | * @author Kevin Bond 18 | */ 19 | final class PendingDocumentType extends AbstractType 20 | { 21 | public function buildForm(FormBuilderInterface $builder, array $options): void 22 | { 23 | $builder->addEventListener( 24 | eventName: FormEvents::PRE_SUBMIT, 25 | listener: static function(FormEvent $event) use ($options) { 26 | if (!$formData = $event->getData()) { 27 | return; 28 | } 29 | 30 | if (!$options['multiple']) { 31 | if ($formData instanceof File) { 32 | $event->setData(new PendingDocument($formData)); 33 | } 34 | 35 | return; 36 | } 37 | 38 | $data = []; 39 | 40 | foreach ($formData as $file) { 41 | if ($file instanceof File) { 42 | $data[] = new PendingDocument($file); 43 | } 44 | } 45 | 46 | $event->setData($data); 47 | }, 48 | priority: -10 49 | ); 50 | } 51 | 52 | public function configureOptions(OptionsResolver $resolver): void 53 | { 54 | $resolver->setDefaults([ 55 | 'data_class' => fn(Options $options) => $options['multiple'] ? null : PendingDocument::class, 56 | ]); 57 | } 58 | 59 | public function getParent(): string 60 | { 61 | return FileType::class; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/HttpFoundation/DocumentResponse.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class DocumentResponse extends StreamedResponse 13 | { 14 | /** 15 | * @param array $headers 16 | */ 17 | public function __construct(Document $document, int $status = 200, array $headers = []) 18 | { 19 | parent::__construct( 20 | static function() use ($document) { 21 | \stream_copy_to_stream($document->read(), \fopen('php://output', 'w') ?: throw new \RuntimeException('Unable to open output stream.')); 22 | }, 23 | $status, 24 | $headers 25 | ); 26 | 27 | if (!$this->headers->has('Last-Modified')) { 28 | $this->setLastModified(\DateTimeImmutable::createFromFormat('U', (string) $document->lastModified()) ?: null); 29 | } 30 | 31 | if (!$this->headers->has('Content-Type')) { 32 | $this->headers->set('Content-Type', $document->mimeType()); 33 | } 34 | } 35 | 36 | /** 37 | * @param array $headers 38 | */ 39 | public static function inline(Document $document, ?string $filename = null, int $status = 200, array $headers = []): self 40 | { 41 | $disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_INLINE, $filename ?? $document->name()); 42 | 43 | return new self($document, $status, \array_merge($headers, ['Content-Disposition' => $disposition])); 44 | } 45 | 46 | /** 47 | * @param array $headers 48 | */ 49 | public static function attachment(Document $document, ?string $filename = null, int $status = 200, array $headers = []): self 50 | { 51 | $disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? $document->name()); 52 | 53 | return new self($document, $status, \array_merge($headers, ['Content-Disposition' => $disposition])); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/ObjectReflector.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | * @internal 12 | */ 13 | final class ObjectReflector 14 | { 15 | private \ReflectionObject $ref; 16 | 17 | /** @var array */ 18 | private array $properties = []; 19 | 20 | public function __construct(private object $object) 21 | { 22 | $this->ref = new \ReflectionObject($object); 23 | } 24 | 25 | /** 26 | * @param array $mappings 27 | * 28 | * @return LazyDocument[] 29 | */ 30 | public function documents(array $mappings): iterable 31 | { 32 | foreach ($mappings as $property => $mapping) { 33 | if ($mapping->virtual) { 34 | $this->set($property, $document = new LazyDocument([])); 35 | 36 | yield $property => $document; 37 | 38 | continue; 39 | } 40 | 41 | $document = $this->get($property); 42 | 43 | if (!$document instanceof LazyDocument) { 44 | continue; 45 | } 46 | 47 | yield $property => $document; 48 | } 49 | } 50 | 51 | public function get(string $property): ?Document 52 | { 53 | $ref = $this->property($property); 54 | 55 | if (!$ref->isInitialized($this->object)) { 56 | return null; 57 | } 58 | 59 | $document = $ref->getValue($this->object); 60 | 61 | return $document instanceof Document ? $document : null; 62 | } 63 | 64 | public function set(string $property, Document $document): void 65 | { 66 | $this->property($property)->setValue($this->object, $document); 67 | } 68 | 69 | private function property(string $name): \ReflectionProperty 70 | { 71 | // todo embedded 72 | 73 | if (\array_key_exists($name, $this->properties)) { 74 | return $this->properties[$name]; 75 | } 76 | 77 | $this->properties[$name] = $this->ref->getProperty($name); 78 | $this->properties[$name]->setAccessible(true); 79 | 80 | return $this->properties[$name]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/HttpKernel/RequestFilesExtractor.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class RequestFilesExtractor 14 | { 15 | public function __construct(private PropertyAccessor $propertyAccessor) 16 | { 17 | } 18 | 19 | public function extractFilesFromRequest( 20 | Request $request, 21 | string $path, 22 | bool $returnArray = false 23 | ): PendingDocument|array|null { 24 | $path = $this->canonizePath($path); 25 | 26 | $files = $this->propertyAccessor->getValue($request->files->all(), $path); 27 | 28 | if ($returnArray) { 29 | if (!$files) { 30 | return []; 31 | } 32 | 33 | if (!\is_array($files)) { 34 | $files = [$files]; 35 | } 36 | 37 | return \array_map( 38 | static fn(UploadedFile $file) => new PendingDocument($file), 39 | $files 40 | ); 41 | } 42 | 43 | if (\is_array($files)) { 44 | throw new \LogicException(\sprintf('Could not extract files from request for "%s" path: expecting a single file, got %d files.', $path, \count($files))); 45 | } 46 | 47 | if (!$files) { 48 | return null; 49 | } 50 | 51 | return new PendingDocument($files); 52 | } 53 | 54 | /** 55 | * Convert HTML paths to PropertyAccessor compatible. 56 | * Examples: "data[file]" -> "[data][file]", "files[]" -> "[files]". 57 | */ 58 | private function canonizePath(string $path): string 59 | { 60 | $path = \preg_replace( 61 | '/\[]$/', 62 | '', 63 | $path 64 | ); 65 | // Correct arguments passed to preg_replace guarantee string return 66 | \assert(\is_string($path)); 67 | 68 | if ('[' !== $path[0]) { 69 | $path = \preg_replace( 70 | '/^([^[]+)/', 71 | '[$1]', 72 | $path 73 | ); 74 | // Correct arguments passed to preg_replace guarantee string return 75 | \assert(\is_string($path)); 76 | } 77 | 78 | return $path; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Document/NullDocument.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class NullDocument implements Document 11 | { 12 | public function path(): string 13 | { 14 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 15 | } 16 | 17 | public function name(): string 18 | { 19 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 20 | } 21 | 22 | public function nameWithoutExtension(): string 23 | { 24 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 25 | } 26 | 27 | public function extension(): string 28 | { 29 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 30 | } 31 | 32 | public function lastModified(): int 33 | { 34 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 35 | } 36 | 37 | public function size(): int 38 | { 39 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 40 | } 41 | 42 | public function checksum(array|string $config = []): string 43 | { 44 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 45 | } 46 | 47 | public function contents(): string 48 | { 49 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 50 | } 51 | 52 | public function read() 53 | { 54 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 55 | } 56 | 57 | public function publicUrl(array $config = []): string 58 | { 59 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 60 | } 61 | 62 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string 63 | { 64 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 65 | } 66 | 67 | public function exists(): bool 68 | { 69 | return false; 70 | } 71 | 72 | public function mimeType(): string 73 | { 74 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 75 | } 76 | 77 | public function refresh(): static 78 | { 79 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([__DIR__.'/src', __DIR__.'/tests']) 5 | ; 6 | $config = new PhpCsFixer\Config(); 7 | 8 | return $config 9 | ->setRules([ 10 | '@Symfony' => true, 11 | '@Symfony:risky' => true, 12 | '@DoctrineAnnotation' => true, 13 | '@PHP71Migration' => true, 14 | '@PHP71Migration:risky' => true, 15 | '@PHPUnit75Migration:risky' => true, 16 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 17 | 'multiline_comment_opening_closing' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'ordered_imports' => [ 20 | 'imports_order' => ['const', 'class', 'function'], 21 | ], 22 | 'ordered_class_elements' => true, 23 | 'native_function_invocation' => ['include' => ['@internal']], 24 | 'explicit_indirect_variable' => true, 25 | 'explicit_string_variable' => true, 26 | 'escape_implicit_backslashes' => true, 27 | 'mb_str_functions' => true, 28 | 'logical_operators' => true, 29 | 'php_unit_method_casing' => ['case' => 'snake_case'], 30 | 'php_unit_test_annotation' => ['style' => 'annotation'], 31 | 'no_unreachable_default_argument_value' => true, 32 | 'declare_strict_types' => false, 33 | 'void_return' => false, 34 | 'single_trait_insert_per_statement' => false, 35 | 'simple_to_complex_string_variable' => true, 36 | 'no_superfluous_phpdoc_tags' => [ 37 | 'allow_mixed' => true, 38 | 'allow_unused_params' => true, 39 | 'remove_inheritdoc' => true, 40 | ], 41 | 'phpdoc_to_comment' => false, 42 | 'function_declaration' => ['closure_function_spacing' => 'none', 'closure_fn_spacing' => 'none'], 43 | 'nullable_type_declaration_for_default_null_value' => true, 44 | 'phpdoc_types_order' => ['null_adjustment' => 'none', 'sort_algorithm' => 'none'], 45 | 'phpdoc_separation' => ['groups' => [ 46 | ['test', 'dataProvider'], 47 | ['template', 'implements', 'extends'], 48 | ['phpstan-type', 'phpstan-import-type'], 49 | ['deprecated', 'link', 'see', 'since'], 50 | ['author', 'copyright', 'license', 'source'], 51 | ['category', 'package', 'subpackage'], 52 | ['property', 'property-read', 'property-write'], 53 | ]], 54 | ]) 55 | ->setRiskyAllowed(true) 56 | ->setFinder($finder) 57 | ; 58 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/Mapping.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | #[\Attribute(\Attribute::TARGET_PROPERTY)] 11 | final class Mapping 12 | { 13 | /** @internal */ 14 | public bool $virtual = false; 15 | 16 | public function __construct( 17 | public string $library, 18 | public ?string $namer = null, 19 | public array|bool $metadata = false, 20 | public bool $autoload = true, 21 | public bool $deleteOnRemove = true, 22 | public bool $deleteOnChange = true, 23 | private bool $nameOnLoad = false, 24 | public array $extra = [], 25 | ) { 26 | if (\is_array($this->metadata) && !$this->metadata) { 27 | throw new \InvalidArgumentException('$metadata cannot be empty.'); 28 | } 29 | 30 | if (\is_array($this->metadata) && !\in_array('path', $this->metadata, true)) { 31 | $this->nameOnLoad = true; 32 | } 33 | } 34 | 35 | /** 36 | * @internal 37 | */ 38 | public function nameOnLoad(): bool 39 | { 40 | return $this->nameOnLoad || $this->virtual; 41 | } 42 | 43 | /** 44 | * @internal 45 | */ 46 | public static function fromProperty(\ReflectionProperty $property, array $mapping = []): self 47 | { 48 | if (\class_exists(Context::class) && $attribute = $property->getAttributes(Context::class)[0] ?? null) { 49 | $mapping = \array_merge($mapping, $attribute->newInstance()->getContext()); 50 | } 51 | 52 | if ($attribute = $property->getAttributes(self::class)[0] ?? null) { 53 | $mapping = \array_merge($mapping, $attribute->newInstance()->toArray()); 54 | } 55 | 56 | return new self( 57 | $mapping['library'] ?? throw new \LogicException(\sprintf('A library is not configured for %s::$%s.', $property->class, $property->name)), 58 | $mapping['namer'] ?? null, 59 | $mapping['metadata'] ?? false, 60 | $mapping['autoload'] ?? true, 61 | $mapping['deleteOnRemove'] ?? true, 62 | $mapping['deleteOnChange'] ?? true, 63 | $mapping['nameOnLoad'] ?? false, 64 | \array_diff_key($mapping, \array_flip([ 65 | 'library', 'namer', 'metadata', 'autoload', 'deleteOnRemove', 'deleteOnChange', 'nameOnLoad', 66 | ])), 67 | ); 68 | } 69 | 70 | /** 71 | * @internal 72 | */ 73 | public function toArray(): array 74 | { 75 | return \array_filter(\array_merge($this->extra, [ 76 | 'library' => $this->library, 77 | 'namer' => $this->namer, 78 | 'metadata' => $this->metadata, 79 | ])); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Serializer/DocumentNormalizer.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface 18 | { 19 | public const LIBRARY = 'library'; 20 | public const METADATA = 'metadata'; 21 | public const RENAME = 'rename'; 22 | 23 | public function __construct(private LibraryRegistry $registry, private Namer $namer) 24 | { 25 | } 26 | 27 | /** 28 | * @param Document $object 29 | */ 30 | final public function normalize(mixed $object, ?string $format = null, array $context = []): string|array 31 | { 32 | if ($metadata = $context[self::METADATA] ?? false) { 33 | return (new SerializableDocument($object, $metadata))->serialize(); 34 | } 35 | 36 | return $object->path(); 37 | } 38 | 39 | final public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 40 | { 41 | return $data instanceof Document; 42 | } 43 | 44 | /** 45 | * @param string|array $data 46 | */ 47 | final public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Document 48 | { 49 | if (\is_string($data)) { 50 | $data = ['path' => $data]; 51 | } 52 | 53 | if ($context[self::RENAME] ?? false) { 54 | unset($data['path']); 55 | } 56 | 57 | $document = new LazyDocument($data); 58 | 59 | if ($library = $context[self::LIBRARY] ?? null) { 60 | $document->setLibrary($this->registry()->get($library)); 61 | } 62 | 63 | if (!isset($data['path'])) { 64 | $document->setNamer($this->namer(), $context); 65 | } 66 | 67 | return $document; 68 | } 69 | 70 | final public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 71 | { 72 | return Document::class === $type; 73 | } 74 | 75 | final public function hasCacheableSupportsMethod(): bool 76 | { 77 | return true; 78 | } 79 | 80 | protected function registry(): LibraryRegistry 81 | { 82 | return $this->registry; 83 | } 84 | 85 | protected function namer(): Namer 86 | { 87 | return $this->namer; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/Mapping/ManagerRegistryMappingProvider.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class ManagerRegistryMappingProvider implements MappingProvider 16 | { 17 | private const MAPPING_TYPES = [DocumentJsonType::NAME, DocumentStringType::NAME]; 18 | private const STRING_MAPPING_TYPES = [DocumentStringType::NAME]; 19 | 20 | public function __construct(private ManagerRegistry $registry) 21 | { 22 | } 23 | 24 | public function get(string $class): array 25 | { 26 | $metadata = $this->registry->getManagerForClass($class)?->getClassMetadata($class); 27 | 28 | if (!$metadata instanceof ClassMetadata) { 29 | return []; // todo support other object managers 30 | } 31 | 32 | $config = []; 33 | 34 | foreach ($metadata->fieldMappings as $field) { 35 | // todo embedded 36 | if (!\in_array($field['type'], self::MAPPING_TYPES, true)) { 37 | continue; 38 | } 39 | 40 | $mapping = Mapping::fromProperty( 41 | $metadata->getReflectionProperty($field['fieldName']), 42 | $field['options'] ?? [], 43 | ); 44 | 45 | if ($mapping->metadata && \in_array($field['type'], self::STRING_MAPPING_TYPES, true)) { 46 | throw new \LogicException(\sprintf('Cannot use "%s" with metadata (%s::$%s).', $field['type'], $metadata->name, $field['fieldName'])); 47 | } 48 | 49 | $config[$field['fieldName']] = $mapping; 50 | } 51 | 52 | // configure virtual documents 53 | foreach ($metadata->reflClass?->getProperties() ?: [] as $property) { 54 | if (isset($config[$property->name])) { 55 | // already configured 56 | continue; 57 | } 58 | 59 | if ($attribute = $property->getAttributes(Mapping::class)[0] ?? null) { 60 | $config[$property->name] = $attribute->newInstance(); 61 | $config[$property->name]->virtual = true; 62 | } 63 | } 64 | 65 | return $config; 66 | } 67 | 68 | public function all(): array 69 | { 70 | $config = []; 71 | 72 | foreach ($this->registry->getManagers() as $manager) { 73 | foreach ($manager->getMetadataFactory()->getAllMetadata() as $metadata) { 74 | $config[$metadata->getName()] = $this->get($metadata->getName()); 75 | } 76 | } 77 | 78 | return \array_filter($config); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Document/SerializableDocument.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | * @internal 12 | */ 13 | final class SerializableDocument implements Document 14 | { 15 | private const ALL_METADATA_FIELDS = ['path', 'lastModified', 'size', 'checksum', 'mimeType', 'publicUrl']; 16 | 17 | private array $fields; 18 | 19 | public function __construct(private Document $document, array|bool $fields) 20 | { 21 | if (false === $fields) { 22 | throw new \InvalidArgumentException('$fields cannot be false.'); 23 | } 24 | 25 | if (true === $fields) { 26 | $fields = self::ALL_METADATA_FIELDS; 27 | } 28 | 29 | $this->fields = $fields; 30 | } 31 | 32 | public function serialize(): array 33 | { 34 | $data = []; 35 | 36 | foreach ($this->fields as $field) { 37 | if (!\method_exists($this->document, $field)) { 38 | throw new \LogicException(\sprintf('Method %d::%s() does not exist.', static::class, $field)); 39 | } 40 | 41 | try { 42 | $data[$field] = $this->document->{$field}(); 43 | } catch (UnableToGeneratePublicUrl) { 44 | // url not available, skip 45 | } 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function path(): string 52 | { 53 | return $this->document->path(); 54 | } 55 | 56 | public function name(): string 57 | { 58 | return $this->document->name(); 59 | } 60 | 61 | public function nameWithoutExtension(): string 62 | { 63 | return $this->document->nameWithoutExtension(); 64 | } 65 | 66 | public function extension(): string 67 | { 68 | return $this->document->extension(); 69 | } 70 | 71 | public function lastModified(): int 72 | { 73 | return $this->document->lastModified(); 74 | } 75 | 76 | public function size(): int 77 | { 78 | return $this->document->size(); 79 | } 80 | 81 | public function checksum(array|string $config = []): string 82 | { 83 | return $this->document->checksum($config); 84 | } 85 | 86 | public function contents(): string 87 | { 88 | return $this->document->contents(); 89 | } 90 | 91 | public function read() 92 | { 93 | return $this->document->read(); 94 | } 95 | 96 | public function publicUrl(array $config = []): string 97 | { 98 | return $this->document->publicUrl($config); 99 | } 100 | 101 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string 102 | { 103 | return $this->document->temporaryUrl($expires, $config); 104 | } 105 | 106 | public function exists(): bool 107 | { 108 | return $this->document->exists(); 109 | } 110 | 111 | public function mimeType(): string 112 | { 113 | return $this->document->mimeType(); 114 | } 115 | 116 | public function refresh(): static 117 | { 118 | $this->document = $this->document->refresh(); 119 | 120 | return $this; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/HttpKernel/PendingDocumentValueResolver.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * @internal 17 | */ 18 | if (\interface_exists(ValueResolverInterface::class)) { 19 | class PendingDocumentValueResolver implements ValueResolverInterface 20 | { 21 | public function __construct( 22 | /** @var ServiceProviderInterface $locator */ 23 | private ServiceProviderInterface $locator 24 | ) { 25 | } 26 | 27 | /** 28 | * @return iterable 29 | */ 30 | public function resolve(Request $request, ArgumentMetadata $argument): iterable 31 | { 32 | $attributes = $argument->getAttributes(UploadedFile::class); 33 | 34 | if ( 35 | empty($attributes) 36 | && PendingDocument::class !== $argument->getType() 37 | ) { 38 | return []; 39 | } 40 | 41 | $path = $attributes[0]?->path 42 | ?? $argument->getName(); 43 | 44 | return [ 45 | $this->extractor()->extractFilesFromRequest( 46 | $request, 47 | $path, 48 | PendingDocument::class !== $argument->getType() 49 | ), 50 | ]; 51 | } 52 | 53 | private function extractor(): RequestFilesExtractor 54 | { 55 | return $this->locator->get(RequestFilesExtractor::class); 56 | } 57 | } 58 | } else { 59 | class PendingDocumentValueResolver implements ArgumentValueResolverInterface 60 | { 61 | public function __construct( 62 | /** @var ServiceProviderInterface $locator */ 63 | private ServiceProviderInterface $locator 64 | ) { 65 | } 66 | 67 | public function supports(Request $request, ArgumentMetadata $argument): bool 68 | { 69 | return PendingDocument::class === $argument->getType() 70 | || !empty($argument->getAttributes(UploadedFile::class)); 71 | } 72 | 73 | /** 74 | * @return iterable 75 | */ 76 | public function resolve(Request $request, ArgumentMetadata $argument): iterable 77 | { 78 | $attributes = $argument->getAttributes(UploadedFile::class); 79 | \assert(!empty($attributes)); 80 | 81 | $path = $attributes[0]?->path 82 | ?? $argument->getName(); 83 | 84 | return [ 85 | $this->extractor()->extractFilesFromRequest( 86 | $request, 87 | $path, 88 | PendingDocument::class !== $argument->getType() 89 | ), 90 | ]; 91 | } 92 | 93 | private function extractor(): RequestFilesExtractor 94 | { 95 | return $this->locator->get(RequestFilesExtractor::class); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Document/PendingDocument.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class PendingDocument implements Document 18 | { 19 | use CalculateChecksumFromStream { readStream as private readStream; } 20 | 21 | private \SplFileInfo $file; 22 | 23 | public function __construct(\SplFileInfo|string $file) 24 | { 25 | $this->file = \is_string($file) ? new \SplFileInfo($file) : $file; 26 | } 27 | 28 | public function path(): string 29 | { 30 | return (string) $this->file; 31 | } 32 | 33 | public function name(): string 34 | { 35 | return $this->file instanceof UploadedFile ? $this->file->getClientOriginalName() : $this->file->getFilename(); 36 | } 37 | 38 | public function nameWithoutExtension(): string 39 | { 40 | return \pathinfo($this->name(), \PATHINFO_FILENAME); 41 | } 42 | 43 | public function extension(): string 44 | { 45 | return $this->file instanceof UploadedFile ? $this->file->getClientOriginalExtension() : $this->file->getExtension(); 46 | } 47 | 48 | public function lastModified(): int 49 | { 50 | return $this->file->getMTime(); 51 | } 52 | 53 | public function size(): int 54 | { 55 | return $this->file->getSize(); 56 | } 57 | 58 | public function checksum(array|string $config = []): string 59 | { 60 | if (\is_string($config)) { 61 | $config = ['checksum_algo' => $config]; 62 | } 63 | 64 | return $this->calculateChecksumFromStream($this->file, new Config($config)); 65 | } 66 | 67 | public function contents(): string 68 | { 69 | return \file_get_contents($this->file) ?: throw new \RuntimeException(\sprintf('Unable to get contents for "%s".', $this->file)); 70 | } 71 | 72 | public function read() 73 | { 74 | return \fopen($this->file, 'r') ?: throw new \RuntimeException(\sprintf('Unable to read "%s".', $this->file)); 75 | } 76 | 77 | public function publicUrl(array $config = []): string 78 | { 79 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 80 | } 81 | 82 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string 83 | { 84 | throw new \BadMethodCallException(\sprintf('%s() is not available.', __METHOD__)); 85 | } 86 | 87 | public function exists(): bool 88 | { 89 | return $this->file->isFile(); 90 | } 91 | 92 | public function mimeType(): string 93 | { 94 | if ($this->file instanceof UploadedFile) { 95 | return $this->file->getClientMimeType(); 96 | } 97 | 98 | // todo add as static property? 99 | return (new FallbackMimeTypeDetector(new FinfoMimeTypeDetector()))->detectMimeTypeFromFile($this->file) 100 | ?? throw new \RuntimeException() 101 | ; 102 | } 103 | 104 | public function refresh(): static 105 | { 106 | \clearstatcache(false, $this->file); 107 | 108 | return $this; 109 | } 110 | 111 | private function readStream(string $path) // @phpstan-ignore-line 112 | { 113 | return $this->read(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Form/DocumentType.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class DocumentType extends AbstractType 23 | { 24 | public function __construct(private ?LibraryRegistry $registry = null, private ?Namer $namer = null) 25 | { 26 | } 27 | 28 | public function buildForm(FormBuilderInterface $builder, array $options): void 29 | { 30 | $builder->addEventListener( 31 | eventName: FormEvents::PRE_SUBMIT, 32 | listener: function(FormEvent $event) use ($options) { 33 | if (!$formData = $event->getData()) { 34 | return; 35 | } 36 | 37 | if (!$options['multiple']) { 38 | if ($formData instanceof File) { 39 | $event->setData($this->store($options, $formData)); 40 | } 41 | 42 | return; 43 | } 44 | 45 | $data = []; 46 | 47 | foreach ($formData as $file) { 48 | if ($file instanceof File) { 49 | $data[] = $this->store($options, $file); 50 | } 51 | } 52 | 53 | $event->setData($data); 54 | }, 55 | priority: -10 56 | ); 57 | } 58 | 59 | public function configureOptions(OptionsResolver $resolver): void 60 | { 61 | $resolver->setDefaults([ 62 | 'library' => null, 63 | 'namer' => 'expression', 64 | 'data_class' => fn(Options $options) => $options['multiple'] ? null : Document::class, 65 | 'namer_context' => [], 66 | ]); 67 | 68 | $resolver->setAllowedTypes('namer_context', 'array'); 69 | 70 | $libraryTypes = [Library::class]; 71 | 72 | if ($this->registry) { 73 | $libraryTypes[] = 'string'; 74 | } 75 | 76 | $resolver->setAllowedTypes('library', $libraryTypes); 77 | $resolver->setAllowedTypes('namer', [Namer::class, 'string', \Stringable::class]); 78 | 79 | $resolver->setRequired('library'); 80 | $resolver->setRequired('namer'); 81 | } 82 | 83 | public function getParent(): string 84 | { 85 | return FileType::class; 86 | } 87 | 88 | private function store(array $options, File $file): Document 89 | { 90 | $library = $options['library']; 91 | $namer = $options['namer']; 92 | $context = $options['namer_context']; 93 | 94 | if (\is_string($namer)) { 95 | $context['namer'] = $namer; 96 | } 97 | 98 | $document = new PendingDocument($file); 99 | $namer = $namer instanceof Namer ? $namer : $this->namer ??= new Namer\MultiNamer(); 100 | $library = $library instanceof Library ? $library : $this->registry?->get($library) ?? throw new \LogicException(); 101 | 102 | return $library->store($namer->generateName($document, $context), $document); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Document/LazyDocument.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @internal 11 | */ 12 | final class LazyDocument implements Document 13 | { 14 | private array $metadata; 15 | private Library $library; 16 | private Document $document; 17 | private Namer $namer; 18 | private array $namerContext; 19 | 20 | public function __construct(string|array $metadata) 21 | { 22 | if (\is_string($metadata)) { 23 | $metadata = ['path' => $metadata]; 24 | } 25 | 26 | $this->metadata = $metadata; 27 | } 28 | 29 | public function setLibrary(Library $library): static 30 | { 31 | $this->library = $library; 32 | 33 | return $this; 34 | } 35 | 36 | public function setNamer(Namer $namer, array $context): static 37 | { 38 | $this->namer = $namer; 39 | $this->namerContext = $context; 40 | 41 | return $this; 42 | } 43 | 44 | public function path(): string 45 | { 46 | if (isset($this->metadata[__FUNCTION__])) { 47 | return $this->metadata[__FUNCTION__]; 48 | } 49 | 50 | if (isset($this->document)) { 51 | return $this->document->path(); 52 | } 53 | 54 | if (!isset($this->namer)) { 55 | throw new \LogicException('A namer is required to generate the path from metadata.'); 56 | } 57 | 58 | $clone = clone $this; 59 | $clone->metadata[__FUNCTION__] = ''; // prevents infinite recursion 60 | 61 | return $this->metadata[__FUNCTION__] = $this->namer->generateName($clone, $this->namerContext); 62 | } 63 | 64 | public function name(): string 65 | { 66 | return $this->metadata[__FUNCTION__] ??= \pathinfo($this->path(), \PATHINFO_BASENAME); 67 | } 68 | 69 | public function nameWithoutExtension(): string 70 | { 71 | return $this->metadata[__FUNCTION__] ??= \pathinfo($this->path(), \PATHINFO_FILENAME); 72 | } 73 | 74 | public function extension(): string 75 | { 76 | return $this->metadata[__FUNCTION__] ??= \pathinfo($this->path(), \PATHINFO_EXTENSION); 77 | } 78 | 79 | public function lastModified(): int 80 | { 81 | return $this->metadata[__FUNCTION__] ??= $this->document()->lastModified(); 82 | } 83 | 84 | public function size(): int 85 | { 86 | return $this->metadata[__FUNCTION__] ??= $this->document()->size(); 87 | } 88 | 89 | public function checksum(array|string $config = []): string 90 | { 91 | if ($config) { 92 | return $this->document()->checksum($config); 93 | } 94 | 95 | return $this->metadata[__FUNCTION__] ??= $this->document()->checksum(); 96 | } 97 | 98 | public function contents(): string 99 | { 100 | return $this->document()->contents(); 101 | } 102 | 103 | public function read() 104 | { 105 | return $this->document()->read(); 106 | } 107 | 108 | public function publicUrl(array $config = []): string 109 | { 110 | if ($config) { 111 | return $this->document()->publicUrl($config); 112 | } 113 | 114 | return $this->metadata[__FUNCTION__] ??= $this->document()->publicUrl(); 115 | } 116 | 117 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string 118 | { 119 | return $this->document()->temporaryUrl($expires, $config); 120 | } 121 | 122 | public function exists(): bool 123 | { 124 | return $this->document()->exists(); 125 | } 126 | 127 | public function mimeType(): string 128 | { 129 | return $this->metadata[__FUNCTION__] ??= $this->document()->mimeType(); 130 | } 131 | 132 | public function refresh(): static 133 | { 134 | $this->document()->refresh(); 135 | $this->metadata = []; 136 | 137 | return $this; 138 | } 139 | 140 | private function document(): Document 141 | { 142 | $this->library ?? throw new \LogicException('A library has not been set for this document.'); 143 | 144 | try { 145 | return $this->document ??= $this->library->open($this->path()); 146 | } finally { 147 | $this->metadata = []; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Document/FlysystemDocument.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class FlysystemDocument implements Document 12 | { 13 | private string $path; 14 | private int $lastModified; 15 | private int $size; 16 | private string $mimeType; 17 | 18 | /** @var array */ 19 | private array $checksum = []; 20 | 21 | /** @var array */ 22 | private array $publicUrl = []; 23 | 24 | /** @var array */ 25 | private array $temporaryUrl = []; 26 | 27 | public function __construct(private FilesystemOperator $filesystem, string $path) 28 | { 29 | if ('' === $this->path = \ltrim($path, '/')) { 30 | throw new \InvalidArgumentException('Path cannot be empty.'); 31 | } 32 | } 33 | 34 | public function path(): string 35 | { 36 | return $this->path; 37 | } 38 | 39 | public function name(): string 40 | { 41 | return \pathinfo($this->path, \PATHINFO_BASENAME); 42 | } 43 | 44 | public function nameWithoutExtension(): string 45 | { 46 | return \pathinfo($this->path, \PATHINFO_FILENAME); 47 | } 48 | 49 | public function extension(): string 50 | { 51 | return \pathinfo($this->path, \PATHINFO_EXTENSION); 52 | } 53 | 54 | public function lastModified(): int 55 | { 56 | return $this->lastModified ??= $this->filesystem->lastModified($this->path); 57 | } 58 | 59 | public function size(): int 60 | { 61 | return $this->size ??= $this->filesystem->fileSize($this->path); 62 | } 63 | 64 | public function checksum(array|string $config = []): string 65 | { 66 | if (\is_string($config)) { 67 | $config = ['checksum_algo' => $config]; 68 | } 69 | 70 | if (isset($this->checksum[$serialized = \serialize($config)])) { 71 | return $this->checksum[$serialized]; 72 | } 73 | 74 | if (!\method_exists($this->filesystem, 'checksum')) { 75 | throw new \LogicException('Checksum is not available for this filesystem.'); 76 | } 77 | 78 | return $this->checksum[$serialized] = $this->filesystem->checksum($this->path, $config); 79 | } 80 | 81 | public function contents(): string 82 | { 83 | return $this->filesystem->read($this->path); 84 | } 85 | 86 | public function read() 87 | { 88 | return $this->filesystem->readStream($this->path); 89 | } 90 | 91 | public function publicUrl(array $config = []): string 92 | { 93 | if (isset($this->publicUrl[$serialized = \serialize($config)])) { 94 | return $this->publicUrl[$serialized]; 95 | } 96 | 97 | if (!\method_exists($this->filesystem, 'publicUrl')) { 98 | throw new \LogicException('A publicUrl is not available for this filesystem.'); 99 | } 100 | 101 | return $this->publicUrl[$serialized] = $this->filesystem->publicUrl($this->path, $config); 102 | } 103 | 104 | public function temporaryUrl(\DateTimeInterface|string $expires, array $config = []): string 105 | { 106 | if (\is_string($expires)) { 107 | $expires = new \DateTimeImmutable($expires); 108 | } 109 | 110 | if (isset($this->temporaryUrl[$serialized = \serialize([$expires, $config])])) { 111 | return $this->temporaryUrl($serialized); 112 | } 113 | 114 | if (!\method_exists($this->filesystem, 'temporaryUrl')) { 115 | throw new \LogicException('A temporaryUrl is not available for this filesystem.'); 116 | } 117 | 118 | return $this->temporaryUrl[$serialized] = $this->filesystem->temporaryUrl($this->path, $expires, $config); 119 | } 120 | 121 | public function exists(): bool 122 | { 123 | return $this->filesystem->fileExists($this->path); 124 | } 125 | 126 | public function mimeType(): string 127 | { 128 | return $this->mimeType ??= $this->filesystem->mimeType($this->path); 129 | } 130 | 131 | public function refresh(): static 132 | { 133 | unset($this->size, $this->lastModified, $this->mimeType); 134 | $this->checksum = $this->publicUrl = $this->temporaryUrl = []; 135 | 136 | return $this; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Document/Namer/ExpressionNamer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ExpressionNamer extends BaseNamer 11 | { 12 | private const DEFAULT_EXPRESSION = '{name}-{rand}{ext}'; 13 | 14 | protected function generate(Document $document, array $context = []): string 15 | { 16 | return \preg_replace_callback( 17 | '#{([\w.:\-\[\]]+)(\|(slug|slugify|lower))?}#', 18 | function($matches) use ($document, $context) { 19 | $value = match ($matches[1]) { 20 | 'name' => $this->slugify($document->nameWithoutExtension()), 21 | 'ext' => self::extensionWithDot($document), 22 | 'checksum' => $document->checksum(), 23 | 'rand' => self::randomString(), 24 | default => $this->parseVariable($matches[1], $document, $context), 25 | }; 26 | 27 | return match ($matches[3] ?? null) { 28 | 'slug', 'slugify' => $this->slugify($value), 29 | 'lower' => \mb_strtolower($value), 30 | default => $value, 31 | }; 32 | }, 33 | $context['expression'] ?? self::DEFAULT_EXPRESSION 34 | ); 35 | } 36 | 37 | private function parseVariable(string $variable, Document $document, array $context): string 38 | { 39 | if (\count($parts = \explode(':', $variable)) > 1) { 40 | return match (\mb_strtolower($parts[0])) { 41 | 'checksum' => self::parseChecksum($document, $parts), 42 | 'rand' => self::randomString((int) $parts[1]), 43 | default => throw new \LogicException(\sprintf('Unable to parse expression variable {%s}.', $variable)), 44 | }; 45 | } 46 | 47 | $value = $this->parseVariableValue($document, $variable, $context); 48 | 49 | if (null === $value || \is_scalar($value) || $value instanceof \Stringable) { 50 | return (string) $value; 51 | } 52 | 53 | throw new \LogicException(\sprintf('Unable to parse expression variable {%s}.', $variable)); 54 | } 55 | 56 | private function parseVariableValue(Document $document, string $variable, array $context): mixed 57 | { 58 | if (\str_starts_with($variable, 'document.')) { 59 | return self::dotAccess($document, \mb_substr($variable, 9)); 60 | } 61 | 62 | if (\array_key_exists($variable, $context)) { 63 | return $context[$variable]; 64 | } 65 | 66 | return self::dotAccess($context, $variable); 67 | } 68 | 69 | /** 70 | * Quick and dirty "dot" accessor that works for objects and arrays. 71 | */ 72 | private static function dotAccess(object|array &$what, string $path): mixed 73 | { 74 | $current = &$what; 75 | 76 | foreach (\explode('.', $path) as $segment) { 77 | if (\is_array($current) && \array_key_exists($segment, $current)) { 78 | $current = &$current[$segment]; 79 | 80 | continue; 81 | } 82 | 83 | if (!\is_object($current)) { 84 | throw new \InvalidArgumentException(\sprintf('Unable to access "%s".', $path)); 85 | } 86 | 87 | if (\method_exists($current, $segment)) { 88 | $current = $current->{$segment}(); 89 | 90 | continue; 91 | } 92 | 93 | foreach (['get', 'has', 'is'] as $prefix) { 94 | if (\method_exists($current, $method = $prefix.\ucfirst($segment))) { 95 | $current = $current->{$method}(); 96 | 97 | continue 2; 98 | } 99 | } 100 | 101 | if (\property_exists($current, $segment)) { 102 | $current = &$current->{$segment}; 103 | 104 | continue; 105 | } 106 | 107 | throw new \InvalidArgumentException(\sprintf('Unable to access "%s".', $path)); 108 | } 109 | 110 | return $current; 111 | } 112 | 113 | private static function parseChecksum(Document $document, array $parts): string 114 | { 115 | unset($parts[0]); // removes "checksum" 116 | 117 | foreach ($parts as $part) { 118 | match (true) { 119 | \is_numeric($part) => $length = (int) $part, 120 | default => $algorithm = $part, 121 | }; 122 | } 123 | 124 | return self::checksum($document, $algorithm ?? null, $length ?? null); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/DependencyInjection/ZenstruckDocumentLibraryExtension.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | final class ZenstruckDocumentLibraryExtension extends ConfigurableExtension 35 | { 36 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 37 | { 38 | if (!$mergedConfig['libraries']) { 39 | return; 40 | } 41 | 42 | // libraries 43 | foreach ($mergedConfig['libraries'] as $name => $service) { 44 | $container->register($id = '.zenstruck_document.library.'.$name, FlysystemLibrary::class) 45 | ->addArgument(new Reference($service)) 46 | ->addTag('document_library', ['key' => $name]) 47 | ; 48 | 49 | $container->registerAliasForArgument($id, Library::class, $name); 50 | } 51 | 52 | $container->register(LibraryRegistry::class) 53 | ->addArgument( 54 | new ServiceLocatorArgument(new TaggedIteratorArgument('document_library', 'key', needsIndexes: true)), 55 | ) 56 | ; 57 | 58 | // namers 59 | $container->register('.zenstruck_document.namer.slugify', SlugifyNamer::class) 60 | ->addArgument(new Reference('slugger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) 61 | ->addTag('document_namer', ['key' => 'slugify']) 62 | ; 63 | $container->register('.zenstruck_document.namer.checksum', ChecksumNamer::class) 64 | ->addArgument(new Reference('slugger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) 65 | ->addTag('document_namer', ['key' => 'checksum']) 66 | ; 67 | $container->register('.zenstruck_document.namer.expression', ExpressionNamer::class) 68 | ->addArgument(new Reference('slugger', ContainerInterface::NULL_ON_INVALID_REFERENCE)) 69 | ->addTag('document_namer', ['key' => 'expression']) 70 | ; 71 | $container->register(Namer::class, MultiNamer::class) 72 | ->addArgument( 73 | new ServiceLocatorArgument(new TaggedIteratorArgument('document_namer', 'key', needsIndexes: true)), 74 | ) 75 | ; 76 | 77 | // normalizer 78 | $container->register('.zenstruck_document.normalizer', LazyDocumentNormalizer::class) 79 | ->addArgument(new ServiceLocatorArgument([ 80 | LibraryRegistry::class => new Reference(LibraryRegistry::class), 81 | Namer::class => new Reference(Namer::class), 82 | ])) 83 | ->addTag('serializer.normalizer') 84 | ; 85 | 86 | // form types 87 | $container->register('.zenstruck_document.form.pending_document_type', PendingDocumentType::class) 88 | ->addTag('form.type') 89 | ; 90 | $container->register('.zenstruck_document.form.document_type', DocumentType::class) 91 | ->setArguments([new Reference(LibraryRegistry::class), new Reference(Namer::class)]) 92 | ->addTag('form.type') 93 | ; 94 | 95 | // value resolver 96 | $container->register('.zenstruck_document.value_resolver.request_files_extractor', RequestFilesExtractor::class) 97 | ->addArgument(new Reference('property_accessor')) 98 | ; 99 | $container->register('.zenstruck_document.value_resolver.pending_document', PendingDocumentValueResolver::class) 100 | ->addTag('controller.argument_value_resolver', ['priority' => 110]) 101 | ->addArgument( 102 | new ServiceLocatorArgument([ 103 | RequestFilesExtractor::class => new Reference('.zenstruck_document.value_resolver.request_files_extractor'), 104 | ]) 105 | ) 106 | ; 107 | 108 | if (isset($container->getParameter('kernel.bundles')['DoctrineBundle'])) { 109 | $this->configureDoctrine($container); 110 | } 111 | } 112 | 113 | private function configureDoctrine(ContainerBuilder $container): void 114 | { 115 | $container->register('.zenstruck_document.doctrine.mapping_provider', ManagerRegistryMappingProvider::class) 116 | ->addArgument(new Reference('doctrine')) 117 | ; 118 | $container->register('.zenstruck_document.doctrine.cache_mapping_provider', CacheMappingProvider::class) 119 | ->setDecoratedService('.zenstruck_document.doctrine.mapping_provider') 120 | ->setArguments([new Reference('cache.system'), new Reference('.inner')]) 121 | ; 122 | $container->register('.zenstruck_document.doctrine.cache_mapping_provider_warmer', DoctrineMappingProviderCacheWarmer::class) 123 | ->addArgument(new Reference('.zenstruck_document.doctrine.cache_mapping_provider')) 124 | ->addTag('kernel.cache_warmer') 125 | ; 126 | $container->register('.zenstruck_document.doctrine.subscriber', LazyDocumentLifecycleSubscriber::class) 127 | ->addArgument(new ServiceLocatorArgument([ 128 | LibraryRegistry::class => new Reference(LibraryRegistry::class), 129 | Namer::class => new Reference(Namer::class), 130 | MappingProvider::class => new Reference('.zenstruck_document.doctrine.mapping_provider'), 131 | ])) 132 | ->addTag('doctrine.event_listener', ['event' => Events::postLoad]) 133 | ->addTag('doctrine.event_listener', ['event' => Events::prePersist]) 134 | ->addTag('doctrine.event_listener', ['event' => Events::preUpdate]) 135 | ->addTag('doctrine.event_listener', ['event' => Events::postRemove]) 136 | ->addTag('doctrine.event_listener', ['event' => Events::postFlush]) 137 | ->addTag('doctrine.event_listener', ['event' => Events::onClear]) 138 | ; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Symfony/Validator/DocumentValidator.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class DocumentValidator extends ConstraintValidator 17 | { 18 | public const KB_BYTES = 1000; 19 | public const MB_BYTES = 1000000; 20 | public const KIB_BYTES = 1024; 21 | public const MIB_BYTES = 1048576; 22 | 23 | private const SUFFICES = [ 24 | 1 => 'bytes', 25 | self::KB_BYTES => 'kB', 26 | self::MB_BYTES => 'MB', 27 | self::KIB_BYTES => 'KiB', 28 | self::MIB_BYTES => 'MiB', 29 | ]; 30 | 31 | public function validate(mixed $value, Constraint $constraint): void 32 | { 33 | if (!$constraint instanceof DocumentConstraint) { 34 | throw new UnexpectedTypeException($constraint, DocumentConstraint::class); 35 | } 36 | 37 | if (null === $value) { 38 | return; 39 | } 40 | 41 | if (!$value instanceof Document) { 42 | throw new UnexpectedValueException($value, Document::class); 43 | } 44 | 45 | if (!$value->exists()) { 46 | $this->context->buildViolation($constraint->notFoundMessage) 47 | ->setParameter('{{ file }}', $this->formatValue($value->path())) 48 | ->addViolation() 49 | ; 50 | 51 | return; 52 | } 53 | 54 | $sizeInBytes = $value->size(); 55 | 56 | if (0 === $sizeInBytes) { 57 | $this->context->buildViolation($constraint->disallowEmptyMessage) 58 | ->setParameter('{{ file }}', $this->formatValue($value->path())) 59 | ->addViolation() 60 | ; 61 | 62 | return; 63 | } 64 | 65 | if ($constraint->maxSize) { 66 | $limitInBytes = $constraint->maxSize; 67 | 68 | if ($sizeInBytes > $limitInBytes) { 69 | [$sizeAsString, $limitAsString, $suffix] = $this->factorizeSizes($sizeInBytes, $limitInBytes, $constraint->binaryFormat); 70 | $this->context->buildViolation($constraint->maxSizeMessage) 71 | ->setParameter('{{ file }}', $this->formatValue($value->path())) 72 | ->setParameter('{{ size }}', $sizeAsString) 73 | ->setParameter('{{ limit }}', $limitAsString) 74 | ->setParameter('{{ suffix }}', $suffix) 75 | ->addViolation() 76 | ; 77 | 78 | return; 79 | } 80 | } 81 | 82 | $mimeTypes = (array) $constraint->mimeTypes; 83 | 84 | if (\property_exists($constraint, 'extensions') && $constraint->extensions) { 85 | $fileExtension = $value->extension(); 86 | 87 | $found = false; 88 | $normalizedExtensions = []; 89 | foreach ((array) $constraint->extensions as $k => $v) { 90 | if (!\is_string($k)) { 91 | $k = $v; 92 | $v = null; 93 | } 94 | 95 | $normalizedExtensions[] = $k; 96 | 97 | if ($fileExtension !== $k) { 98 | continue; 99 | } 100 | 101 | $found = true; 102 | 103 | if (null === $v) { 104 | if (!\class_exists(MimeTypes::class)) { 105 | throw new LogicException('You cannot validate the mime-type of files as the Mime component is not installed. Try running "composer require symfony/mime".'); 106 | } 107 | 108 | $mimeTypesHelper = MimeTypes::getDefault(); 109 | $v = $mimeTypesHelper->getMimeTypes($k); 110 | } 111 | 112 | $mimeTypes = $mimeTypes ? \array_intersect($v, $mimeTypes) : (array) $v; 113 | 114 | break; 115 | } 116 | 117 | if (!$found) { 118 | $this->context->buildViolation($constraint->extensionsMessage) 119 | ->setParameter('{{ file }}', $this->formatValue($value->path())) 120 | ->setParameter('{{ extension }}', $this->formatValue($fileExtension)) 121 | ->setParameter('{{ extensions }}', $this->formatValues($normalizedExtensions)) 122 | ->addViolation() 123 | ; 124 | } 125 | } 126 | 127 | if ($mimeTypes) { 128 | $mime = $value->mimeType(); 129 | 130 | foreach ($mimeTypes as $mimeType) { 131 | if ($mimeType === $mime) { 132 | return; 133 | } 134 | 135 | if ($discrete = \mb_strstr($mimeType, '/*', true)) { 136 | if (\mb_strstr($mime, '/', true) === $discrete) { 137 | return; 138 | } 139 | } 140 | } 141 | 142 | $this->context->buildViolation($constraint->mimeTypesMessage) 143 | ->setParameter('{{ file }}', $this->formatValue($value->path())) 144 | ->setParameter('{{ type }}', $this->formatValue($mime)) 145 | ->setParameter('{{ types }}', $this->formatValues($mimeTypes)) 146 | ->addViolation() 147 | ; 148 | } 149 | } 150 | 151 | private static function moreDecimalsThan(string $double, int $numberOfDecimals): bool 152 | { 153 | return \mb_strlen($double) > \mb_strlen((string) \round((float) $double, $numberOfDecimals)); 154 | } 155 | 156 | /** 157 | * Convert the limit to the smallest possible number 158 | * (i.e. try "MB", then "kB", then "bytes"). 159 | */ 160 | private function factorizeSizes(int $size, int|float $limit, bool $binaryFormat): array 161 | { 162 | if ($binaryFormat) { 163 | $coef = self::MIB_BYTES; 164 | $coefFactor = self::KIB_BYTES; 165 | } else { 166 | $coef = self::MB_BYTES; 167 | $coefFactor = self::KB_BYTES; 168 | } 169 | 170 | // If $limit < $coef, $limitAsString could be < 1 with less than 3 decimals. 171 | // In this case, we would end up displaying an allowed size < 1 (eg: 0.1 MB). 172 | // It looks better to keep on factorizing (to display 100 kB for example). 173 | while ($limit < $coef) { 174 | $coef /= $coefFactor; 175 | } 176 | 177 | $limitAsString = (string) ($limit / $coef); 178 | 179 | // Restrict the limit to 2 decimals (without rounding! we 180 | // need the precise value) 181 | while (self::moreDecimalsThan($limitAsString, 2)) { 182 | $coef /= $coefFactor; 183 | $limitAsString = (string) ($limit / $coef); 184 | } 185 | 186 | // Convert size to the same measure, but round to 2 decimals 187 | $sizeAsString = (string) \round($size / $coef, 2); 188 | 189 | // If the size and limit produce the same string output 190 | // (due to rounding), reduce the coefficient 191 | while ($sizeAsString === $limitAsString) { 192 | $coef /= $coefFactor; 193 | $limitAsString = (string) ($limit / $coef); 194 | $sizeAsString = (string) \round($size / $coef, 2); 195 | } 196 | 197 | return [$sizeAsString, $limitAsString, self::SUFFICES[$coef]]; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Document/Library/Bridge/Doctrine/Persistence/EventListener/DocumentLifecycleSubscriber.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class DocumentLifecycleSubscriber 23 | { 24 | /** @var callable[] */ 25 | private array $pendingOperations = []; 26 | 27 | /** @var callable[] */ 28 | private array $onFailureOperations = []; 29 | 30 | public function __construct( 31 | private LibraryRegistry $registry, 32 | private MappingProvider $mappingProvider, 33 | private Namer $namer, 34 | ) { 35 | } 36 | 37 | /** 38 | * @param LifecycleEventArgs $event 39 | */ 40 | final public function postLoad(LifecycleEventArgs $event): void 41 | { 42 | $object = $event->getObject(); 43 | 44 | if (!$mappings = \array_filter($this->mappingProvider()->get($object::class), static fn(Mapping $m) => $m->autoload)) { 45 | return; 46 | } 47 | 48 | foreach ((new ObjectReflector($object))->documents($mappings) as $property => $document) { 49 | $document->setLibrary($this->registry()->get($mappings[$property]->library)); 50 | 51 | if ($mappings[$property]->nameOnLoad()) { 52 | $document->setNamer($this->namer(), self::namerContext($mappings[$property], $object)); 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * @param LifecycleEventArgs $event 59 | */ 60 | final public function postRemove(LifecycleEventArgs $event): void 61 | { 62 | $object = $event->getObject(); 63 | 64 | if (!$mappings = \array_filter($this->mappingProvider()->get($object::class), static fn(Mapping $m) => $m->deleteOnRemove)) { 65 | return; 66 | } 67 | 68 | foreach ($mappings as $property => $mapping) { 69 | $ref ??= new ObjectReflector($object); 70 | $document = $ref->get($property); 71 | 72 | if ($document instanceof Document && $document->exists()) { 73 | $this->registry()->get($mapping->library)->delete($document->path()); 74 | } 75 | } 76 | } 77 | 78 | /** 79 | * @param LifecycleEventArgs $event 80 | */ 81 | final public function prePersist(LifecycleEventArgs $event): void 82 | { 83 | $object = $event->getObject(); 84 | 85 | if (!$mappings = $this->mappingProvider()->get($object::class)) { 86 | return; 87 | } 88 | 89 | foreach ($mappings as $property => $mapping) { 90 | $ref ??= new ObjectReflector($object); 91 | 92 | if ($mapping->virtual) { 93 | // set virtual document 94 | $ref->set($property, (new LazyDocument([])) 95 | ->setLibrary($this->registry()->get($mapping->library)) 96 | ->setNamer($this->namer(), self::namerContext($mapping, $object)) 97 | ); 98 | 99 | continue; 100 | } 101 | 102 | $document = $ref->get($property); 103 | 104 | if (!$document instanceof Document) { 105 | continue; 106 | } 107 | 108 | if ($document instanceof PendingDocument) { 109 | $document = $this->registry()->get($mapping->library)->store( 110 | $path = $this->namer()->generateName($document, self::namerContext($mapping, $object)), 111 | $document 112 | ); 113 | $this->onFailureOperations[] = fn() => $this->registry()->get($mapping->library)->delete($path); 114 | 115 | $ref->set($property, $document); 116 | } 117 | 118 | if (!$document instanceof SerializableDocument && $mapping->metadata) { 119 | // save with metadata 120 | $ref->set($property, new SerializableDocument($document, $mapping->metadata)); 121 | } 122 | } 123 | } 124 | 125 | /** 126 | * @param PreUpdateEventArgs|ORMPreUpdateEventsArgs $event 127 | */ 128 | final public function preUpdate(PreUpdateEventArgs|ORMPreUpdateEventsArgs $event): void 129 | { 130 | $object = $event->getObject(); 131 | 132 | if (!$mappings = $this->mappingProvider()->get($object::class)) { 133 | return; 134 | } 135 | 136 | foreach ($mappings as $property => $mapping) { 137 | if (!$event->hasChangedField($property)) { 138 | continue; 139 | } 140 | 141 | $old = $event->getOldValue($property); 142 | $new = $event->getNewValue($property); 143 | 144 | if ($new instanceof PendingDocument) { 145 | $new = $this->registry()->get($mapping->library)->store( 146 | $path = $this->namer()->generateName($new, self::namerContext($mapping, $object)), 147 | $new 148 | ); 149 | $this->onFailureOperations[] = fn() => $this->registry()->get($mapping->library)->delete($path); 150 | 151 | $event->setNewValue($property, $new); 152 | } 153 | 154 | if ($mapping->deleteOnChange && $new instanceof Document && $old instanceof Document && $new->path() !== $old->path()) { 155 | // document was changed, delete old from library 156 | $this->pendingOperations[] = fn() => $this->registry()->get($mapping->library)->delete($old->path()); 157 | } 158 | 159 | if ($new instanceof Document && !$new instanceof SerializableDocument && $mapping->metadata) { 160 | // save with metadata 161 | $event->setNewValue($property, new SerializableDocument($new, $mapping->metadata)); 162 | } 163 | 164 | if ($mapping->deleteOnChange && $old instanceof Document && null === $new) { 165 | // document was removed, delete from library 166 | $this->pendingOperations[] = fn() => $this->registry()->get($mapping->library)->delete($old->path()); 167 | } 168 | } 169 | } 170 | 171 | final public function postFlush(): void 172 | { 173 | foreach ($this->pendingOperations as $operation) { 174 | $operation(); 175 | } 176 | 177 | $this->pendingOperations = $this->onFailureOperations = []; 178 | } 179 | 180 | final public function onClear(): void 181 | { 182 | foreach ($this->onFailureOperations as $operation) { 183 | $operation(); 184 | } 185 | 186 | $this->pendingOperations = $this->onFailureOperations = []; 187 | } 188 | 189 | protected function registry(): LibraryRegistry 190 | { 191 | return $this->registry; 192 | } 193 | 194 | protected function mappingProvider(): MappingProvider 195 | { 196 | return $this->mappingProvider; 197 | } 198 | 199 | protected function namer(): Namer 200 | { 201 | return $this->namer; 202 | } 203 | 204 | private static function namerContext(Mapping $mapping, object $object): array 205 | { 206 | return \array_merge($mapping->toArray(), ['this' => $object]); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zenstruck/document-library 2 | 3 | ## Basic Usage 4 | 5 | Create a `Library` from any [Flysystem](https://flysystem.thephpleague.com/) filesystem 6 | instance: 7 | 8 | ```php 9 | use Zenstruck\Document\Library\FlysystemLibrary; 10 | 11 | /** @var \League\Flysystem\FilesystemOperator $filesystem */ 12 | 13 | $library = new FlysystemLibrary($filesystem); 14 | ``` 15 | 16 | ### Library API 17 | 18 | ```php 19 | /** @var \Zenstruck\Document\Library $library */ 20 | 21 | // "Open" Documents 22 | $document = $library->open('path/to/file.txt'); // \Zenstruck\Document 23 | 24 | // Check if Document exists 25 | $library->has('some/file.txt'); // bool (whether the document exists or not) 26 | 27 | // Store Documents 28 | $library->store('some/file.txt', 'file contents'); // \Zenstruck\Document 29 | 30 | /** @var \SplFileInfo $file */ 31 | $library->store('some/file.txt', $file); // \Zenstruck\Document 32 | 33 | /** @var \Zenstruck\Document $document */ 34 | $library->store('some/file.txt', $document); // \Zenstruck\Document 35 | 36 | // Delete Documents 37 | $library->delete('some/file.txt'); // self (fluent) 38 | ``` 39 | 40 | ### Document API 41 | 42 | ```php 43 | /** @var \Zenstruck\Document $document */ 44 | 45 | $document->path(); // "path/to/file.txt" 46 | $document->name(); // "file.txt" 47 | $document->extension(); // "txt" 48 | $document->nameWithoutExtension(); // "file" 49 | $document->lastModified(); // int (timestamp) 50 | $document->size(); // int (bytes) 51 | $document->mimeType(); // "text/plain" 52 | $document->checksum(); // string (uses default checksum algorithm for flysystem provider) 53 | $document->checksum('sha1'); // "string" (specify checksum algorithm) 54 | $document->read(); // resource (file contents as stream) 55 | $document->contents(); // string (file contents) 56 | $document->publicUrl(); // string (public url for document) 57 | $document->temporaryUrl(new \DateTime('+30 minutes')); // string (expiring url for document) 58 | $document->temporaryUrl('+30 minutes'); // equivalent to above 59 | $document->exists(); // bool (whether the document exists or not) 60 | $document->refresh(); // self (clears any cached metadata) 61 | ``` 62 | 63 | ## Namers 64 | 65 | Namer's can be used to generate the path for a document before saving. 66 | 67 | ### `ChecksumNamer` 68 | 69 | ```php 70 | /** @var \Zenstruck\Document\Library $library */ 71 | /** @var \Zenstruck\Document\PendingDocument $document */ 72 | 73 | $namer = new \Zenstruck\Document\Namer\ChecksumNamer(); 74 | 75 | $library->store($namer->generateName($document), $document); // stored as "." 76 | 77 | // customize the checksum algorithm 78 | $library->store($namer->generateName($document, ['alg' => 'sha1']), $document); // stored as "." 79 | 80 | // customize the checksum length (first x characters) 81 | $library->store($namer->generateName($document, ['length' => 7]), $document); // stored as "." 82 | ``` 83 | 84 | ### `SlugifyNamer` 85 | 86 | ```php 87 | /** @var \Zenstruck\Document\Library $library */ 88 | /** @var \Zenstruck\Document\PendingDocument $document */ 89 | 90 | $namer = new \Zenstruck\Document\Namer\ChecksumNamer(); 91 | 92 | $library->store($namer->generateName($document), $document); // stored as "" 93 | ``` 94 | 95 | ### `ExpressionNamer` 96 | 97 | ```php 98 | use Zenstruck\Document\Namer\ExpressionNamer; 99 | 100 | /** @var \Zenstruck\Document\Library $library */ 101 | /** @var \Zenstruck\Document\PendingDocument $document */ 102 | 103 | $namer = new ExpressionNamer(); 104 | 105 | // Default expression 106 | $path = $namer->generateName($document); // "-." 107 | 108 | // Customize expression 109 | $path = $namer->generateName($document, [ 110 | 'expression' => 'some/prefix/{name}-{checksum:7}{ext}', 111 | ]); // "some/prefix/-." 112 | 113 | // Complex expression 114 | $path = $namer->generateName($document, [ 115 | 'expression' => 'profile-images/{user.username|lower}{ext}', 116 | 'user' => $userObject, 117 | ]); // "profile-images/." 118 | 119 | $library->store($path, $document); // stored as "" 120 | ``` 121 | 122 | #### Available Variables 123 | 124 | - `{name}`: slugified document filename without extension. 125 | - `{ext}`: document extension _with dot_ (ie `.txt` or _empty string_ if no extension). 126 | - `{checksum}`: document checksum (uses default algorithm for flysystem provider). 127 | - `{checksum:alg}`: document checksum using `alg` as the algorithm (ie `{checksum:sha1}`). 128 | - `{checksum:n}`: first `n` characters of document checksum (ie `{checksum:7}`). 129 | - `{checksum:alg:n}`: first `n` characters of document checksum using `alg` as the algorithm (ie `{checksum:sha1:7}`). 130 | - `{rand}`: random `6` character string. 131 | - `{rand:n}`: random `n` character string. 132 | - `{document.*}`: any raw document method (ie `{document.lastModified}`). 133 | - `{x}`: any passed `$context` key, the value must be _stringable_. 134 | - `{x.y}`: if passed `$context` value for key `x` is an object, call method `y` on it, the return 135 | value must be _stringable_. 136 | 137 | #### Available Modifiers 138 | 139 | - `{variable|lower}`: lowercase `{variable}`. 140 | - `{variable|slug}`: slugify `{variable}`. 141 | 142 | ### `MultiNamer` 143 | 144 | ```php 145 | use Zenstruck\Document\Namer\MultiNamer; 146 | use Zenstruck\Document\Namer\Expression; 147 | 148 | /** @var \Zenstruck\Document\PendingDocument $document */ 149 | 150 | $namer = new MultiNamer(); // defaults to containing above namers, with "expression" as the default 151 | 152 | // defaults to ExpressionNamer (with its default expression) 153 | $path = $namer->generateName($document); // "-." 154 | 155 | $path = $namer->generateName($document, ['namer' => 'checksum']); // use the checksum namer 156 | $path = $namer->generateName($document, ['namer' => 'slugify']); // use the slugify namer 157 | $path = $namer->generateName($document, ['namer' => 'expression', 'expression' => '{name}{ext}']); // use the expression namer 158 | $path = $namer->generateName($document, ['namer' => new Expression('{name}{ext}')]); // equivalent to above 159 | $path = $namer->generateName($document, ['namer' => 'expression:{name}{ext}']); // equivalent to above 160 | 161 | // Customize the default namer 162 | $namer = new MultiNamer(defaultContext: ['namer' => 'checksum']); 163 | 164 | $path = $namer->generateName($document); // "." 165 | ``` 166 | 167 | The `MultiNamer` can also use a `callable` for the `namer`: 168 | 169 | ```php 170 | use Zenstruck\Document; 171 | 172 | /** @var \Zenstruck\Document\Namer\MultiNamer $namer */ 173 | /** @var Document $document */ 174 | 175 | $path = $namer->generateName($document, ['namer' => function(Document $document, array $context):string { 176 | // return string 177 | }]); 178 | ``` 179 | 180 | ### Custom Namer 181 | 182 | You can create your own namer by having an object implement the `Zenstruck\Document\Namer` 183 | interface and register it with the [`MultiNamer`](#multinamer): 184 | 185 | ```php 186 | use Zenstruck\Document\Namer\MultiNamer; 187 | 188 | /** @var \Zenstruck\Document\Namer $customNamer1 */ 189 | /** @var \Zenstruck\Document\Namer $customNamer2 */ 190 | 191 | $namer = new MultiNamer( 192 | namers: ['custom1' => $customNamer1, 'custom2' => $customNamer2], 193 | defaultContext: ['namer' => 'custom1'], 194 | ); 195 | 196 | $path = $namer->generateName($document); // use the custom1 namer as it's the default 197 | $path = $namer->generateName($document, ['namer' => 'custom2']); // use the custom2 namer 198 | $path = $namer->generateName($document, ['namer' => 'checksum']); // default namers are still available 199 | ``` 200 | 201 | ## Symfony 202 | 203 | ### Bundle 204 | 205 | A Symfony bundle is available to configure different libraries for your application. 206 | Enable in your `config/bundles.php`: 207 | 208 | ```php 209 | // config/bundles.php 210 | 211 | return [ 212 | // ... 213 | Zenstruck\Document\Library\Bridge\Symfony\ZenstruckDocumentLibraryBundle::class => ['all' => true] 214 | ]; 215 | ``` 216 | 217 | > **Note**: the remaining Symfony docs will assume you have the bundle enabled. 218 | 219 | #### Configuration 220 | 221 | Configure your application's libraries: 222 | 223 | ```yaml 224 | # config/packages/zenstruck_filesystem.yaml 225 | 226 | zenstruck_document_library: 227 | libraries: 228 | public: 'service.id.for.flysystem.operator' 229 | private: 'service.id.for.flysystem.operator' 230 | ``` 231 | 232 | #### Services 233 | 234 | Your defined libraries can be autowired: 235 | 236 | ```php 237 | use Zenstruck\Document\Library; 238 | 239 | class SomeController 240 | { 241 | public function someAction( 242 | Library $public, // the public library as defined in your config 243 | Library $private, // the private library as defined in your config 244 | ) { 245 | $document = $public->open('some/path'); 246 | 247 | // ... 248 | } 249 | } 250 | ``` 251 | 252 | ### Form 253 | 254 | A `DocumentType` form type is provided - it extends Symfony's native 255 | [`FileType`](https://symfony.com/doc/current/reference/forms/types/file.html). 256 | This type takes one or more uploaded files and stores them in the configured 257 | `library` and names them using the configured [`namer`](#namers). 258 | 259 | ```php 260 | use Zenstruck\Document\Library\Bridge\Symfony\Form\DocumentType; 261 | use Zenstruck\Document\Namer\Expression; 262 | 263 | /** @var \Symfony\Component\Form\FormBuilderInterface $builder */ 264 | 265 | $builder->add('attachment', DocumentType::class, [ 266 | 'library' => 'public', 267 | 'namer' => new Expression('users/profile-images/{checksum}{ext}'), 268 | ]); 269 | 270 | // multiple 271 | $builder->add('attachments', DocumentType::class, [ 272 | 'multiple' => true, 273 | 'library' => 'public', 274 | 'namer' => new Expression('users/profile-images/{checksum}{ext}'), 275 | ]); 276 | ``` 277 | 278 | > **Note**: If no `namer` is configured, defaults to the `ExpressionNamer` with its configured 279 | > default expression. 280 | 281 | ### Validator 282 | 283 | A `DocumentConstraint` and `DocumentValidator` is provided. The `DocumentConstraint` has the 284 | same API as Symfony's native [`File`](https://symfony.com/doc/current/reference/constraints/File.html) 285 | constraint. 286 | 287 | ```php 288 | use Zenstruck\Document\Library\Bridge\Symfony\Validator\DocumentConstraint; 289 | 290 | /** @var \Symfony\Component\Validator\Validator\ValidatorInterface $validator */ 291 | /** @var \Zenstruck\Document $document */ 292 | 293 | $validator->validate($document, new DocumentConstraint(maxSize: '1M')); 294 | ``` 295 | 296 | ### Argument injection 297 | 298 | An argument value resolver is provided that enables injecting `PendingDocument`'s 299 | (from `$request->files`) directly into your controllers: 300 | 301 | ```php 302 | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 303 | use Zenstruck\Document\Attribute\UploadedFile; 304 | use Zenstruck\Document\PendingDocument; 305 | 306 | class MyController extends AbstractController 307 | { 308 | public function __invoke( 309 | // Inject directly from $request->files->get('file') 310 | ?PendingDocument $file, 311 | 312 | // Inject by path, which is the same format as `name` on HTML `` 313 | #[UploadedFile('data[file]')] 314 | ?PendingDocument $anotherFile, 315 | 316 | // Inject array of pending documents from $request->files->get('files') 317 | #[UploadedFile] 318 | array $files, 319 | 320 | // Inject array of pending documents by path 321 | #[UploadedFile('data[files]')] 322 | array $anotherFiles 323 | ): Response { 324 | // Handle files and prepare response 325 | } 326 | } 327 | ``` 328 | 329 | ### Response 330 | 331 | A `DocumentResponse` object is provided to easily create a Symfony response 332 | from a `Document`: 333 | 334 | ```php 335 | use Zenstruck\Document\Library\Bridge\Symfony\HttpFoundation\DocumentResponse; 336 | 337 | /** @var \Zenstruck\Document $document */ 338 | 339 | $response = new DocumentResponse($document); // auto-adds content-type/last-modified headers 340 | 341 | // create inline/attachment responses 342 | $response = DocumentResponse::attachment($document); // auto names by the filename 343 | $response = DocumentResponse::inline($document); // auto names by the filename 344 | 345 | // customize the filename used for the content-disposition header 346 | $response = DocumentResponse::attachment($document, 'different-name.txt'); 347 | $response = DocumentResponse::inline($document, 'different-name.txt'); 348 | ``` 349 | 350 | ### Doctrine ORM Integration 351 | 352 | A custom DBAL type is provided to map `Document` instances to a json column and back. Add 353 | a document property to your entity (using `Zenstruck\Document` as the _column type_ and 354 | property typehint) and map the filesystem using the `Mapping` attribute: 355 | 356 | ```php 357 | use Doctrine\ORM\Mapping as ORM; 358 | use Zenstruck\Document; 359 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 360 | 361 | class User 362 | { 363 | // ... 364 | 365 | #[Mapping(library: 'public')] 366 | #[ORM\Column(type: Document::class, nullable: true)] 367 | public ?Document $image = null; 368 | 369 | /** 370 | * Alternatively, map with column options: 371 | */ 372 | #[ORM\Column(type: Document::class, nullable: true, options: ['library' => 'public'])] 373 | public ?Document $image = null; 374 | // ... 375 | } 376 | ``` 377 | 378 | > **Warning**: It's important that the typehint is the `Zenstruck\Document` interface and 379 | > not a concrete document object. Behind the scenes, it is populated with different 380 | > implementations of this interface. 381 | 382 | Usage: 383 | 384 | ```php 385 | /** @var \Zenstruck\Document\Library $library */ 386 | /** @var \Doctrine\ORM\EntityManagerInterface $em */ 387 | 388 | // persist 389 | $user = new User(); 390 | $user->image = $library->open('first/image.png'); 391 | $em->persist($user); 392 | $em->flush(); // "first/image.png" is saved to the "user" db's "image" column 393 | 394 | // autoload 395 | $user = $em->find(User::class, 1); 396 | $user->image->contents(); // call any Document method (lazily loads from library) 397 | 398 | // update 399 | $user->image = $library->open('second/image.png'); 400 | $em->flush(); // "second/image.png" is saved and "first/image.png" is deleted from the library 401 | 402 | // delete 403 | $em->remove($user); 404 | $em->flush(); // "second/image.png" is deleted from the library 405 | ``` 406 | 407 | #### Persist/Update with `PendingDocument` 408 | 409 | `Zenstruck\Document\PendingDocument` is a `Zenstruck\Document` implementation that wraps 410 | a real, local file. 411 | 412 | ```php 413 | use Zenstruck\Document\PendingDocument; 414 | 415 | $document = new PendingDocument('/path/to/some/file.txt'); 416 | $document->path(); "/path/to/some/file.txt" 417 | // ... 418 | ``` 419 | 420 | A `PendingDocument` can be also be created with a `Symfony\Component\HttpFoundation\File\UploadedFile`: 421 | 422 | ```php 423 | use Zenstruck\Document\PendingDocument; 424 | 425 | /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ 426 | 427 | $document = new PendingDocument($file); 428 | $document->name(); // string - UploadedFile::getClientOriginalName() 429 | $document->extension(); // string - UploadedFile::getClientOriginalExtension() 430 | $document->mimeType(); // string - UploadedFile::getClientMimeType() 431 | // ... 432 | ``` 433 | 434 | You can set `PendingDocument`'s to your entity's `Document` properties. These are automatically 435 | named (on persist and update) using the [Namer system](#namers) and configured by your `Mapping`. 436 | 437 | ```php 438 | use Doctrine\ORM\Mapping as ORM; 439 | use Zenstruck\Document; 440 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 441 | use Zenstruck\Document\Namer\Expression; 442 | 443 | class User 444 | { 445 | #[ORM\Column] 446 | public string $username; 447 | 448 | /** 449 | * PendingDocument's set on this property will be automatically named 450 | * using the "checksum" namer. 451 | */ 452 | #[Mapping(library: 'public', namer: 'checksum')] 453 | #[ORM\Column(type: Document::class, nullable: true)] 454 | public ?Document $image = null; 455 | 456 | /** 457 | * PendingDocument's set on this property will be automatically named 458 | * using the "expression" namer with the configured "expression". 459 | * 460 | * Note the {this.username} syntax. "this" is the current instance of the entity. 461 | */ 462 | #[Mapping( 463 | library: 'public', 464 | namer: new Expression('user/{this.username}-{checksum}{ext}'), // saved to library as "user/." 465 | )] 466 | #[ORM\Column(type: Document::class, nullable: true)] 467 | public ?Document $image = null; 468 | } 469 | ``` 470 | 471 | > **Note**: If not on PHP 8.1+, the `namer: new Expression()` syntax above is invalid. Use 472 | > `namer: 'expression:user/{this.username}-{checksum}{ext}'` instead. 473 | 474 | > **Note**: If no `namer` is configured, defaults to the `ExpressionNamer` with its configured 475 | > default expression. 476 | 477 | #### PendingDocument Form Type 478 | 479 | This form type is similar to [`DocumentType`](#form) but instead of storing 480 | the uploaded document to a library, it just converts to a `PendingDocument`. 481 | This allows your entity mapping to be used to name/store. 482 | 483 | ```php 484 | use Zenstruck\Document\Library\Bridge\Symfony\Form\PendingDocumentType; 485 | use Zenstruck\Document\Namer\Expression; 486 | 487 | /** @var \Symfony\Component\Form\FormBuilderInterface $builder */ 488 | 489 | $builder->add('attachment', PendingDocumentType::class); 490 | ``` 491 | 492 | #### Store Additional Document Metadata 493 | 494 | You can choose to store additional document metadata in the database column 495 | (since it is a json type). This is useful to avoid retrieving this data 496 | lazily from the filesystem. 497 | 498 | ```php 499 | use Doctrine\ORM\Mapping as ORM; 500 | use Zenstruck\Document; 501 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 502 | 503 | class User 504 | { 505 | #[Mapping(library: 'public', metadata: true)] // will store path, lastModified, size, checksum, mimeType and publicUrl 506 | #[ORM\Column(type: Document::class, nullable: true)] 507 | public ?Document $image = null; 508 | 509 | // customize the saved metadata 510 | #[Mapping(library: 'public', metadata: ['path', 'publicUrl', 'lastModified'])] // will store just path, publicUrl and lastModified 511 | #[ORM\Column(type: Document::class, nullable: true)] 512 | public ?Document $image = null; 513 | } 514 | ``` 515 | 516 | Usage: 517 | 518 | ```php 519 | /** @var \Zenstruck\Document\Library $library */ 520 | /** @var \Doctrine\ORM\EntityManagerInterface $em */ 521 | 522 | // persist 523 | $user = new User(); 524 | $user->image = $library->open('first/image.png'); 525 | $em->persist($user); 526 | $em->flush(); // json object with "path", "publicUrl" and "lastModified" saved to "user" db's "image" column 527 | 528 | // autoload 529 | $user = $em->find(User::class, 1); 530 | $user->image->publicUrl(); // loads from json object (does not load from library) 531 | $user->image->lastModified(); // loads from json object (does not load from library) 532 | $user->image->read(); // will load document from filesystem 533 | ``` 534 | 535 | #### Update Metadata 536 | 537 | The following method is required to update the metadata stored in the database: 538 | 539 | ```php 540 | $user->image = clone $user->image; // note the clone (this is required for doctrine to see the update) 541 | $em->flush(); // metadata recalculated and saved 542 | ``` 543 | 544 | #### Name on Load 545 | 546 | When [storing additional metadata](#store-additional-document-metadata), if you don't configure 547 | `path` in your `metadata` array, this triggers lazily generating the document's `path` after 548 | loading the entity. This can be useful if your backend filesystem structure can change. Since 549 | the path isn't stored in the database, you only have to update the mapping in your entity to 550 | _mass-change_ all document's location. 551 | 552 | ```php 553 | use Doctrine\ORM\Mapping as ORM; 554 | use Zenstruck\Document; 555 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 556 | use Zenstruck\Document\Namer\Expression; 557 | 558 | class User 559 | { 560 | #[ORM\Column] 561 | public string $username; 562 | 563 | #[Mapping( 564 | library: 'public', 565 | metadata: ['checksum', 'size', 'extension'], // just store the checksum, size and file extension in the db 566 | namer: new Expression('images/{this.username}-{checksum:7}{ext}'), // use "namer: 'expression:images/{this.username}-{checksum:7}{ext}'" on PHP 8.0 567 | )] 568 | #[ORM\Column(type: Document::class, nullable: true)] 569 | public ?Document $image = null; 570 | } 571 | ``` 572 | 573 | Now, when you load the entity, the `path` will be calculated (with the namer) when 574 | first accessing a document method that requires it (ie `Document::contents()`). 575 | 576 | Note in the above example, the expression is `images/{this.username}-{checksum:7}{ext}`. 577 | Say you've renamed the `images` directory on your filesystem to `profile-images`. You 578 | need only update the mapping's expression to `profile-images/{this.username}-{checksum:7}{ext}`. 579 | The next time the document is loaded, it will point to the new directory. 580 | 581 | You can force this behaviour even if the `path` is stored in the database: 582 | 583 | ```php 584 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 585 | 586 | class User 587 | { 588 | #[Mapping( 589 | library: 'public', 590 | metadata: ['checksum', 'size', 'extension'], 591 | namer: new Expression('images/{this.username}-{checksum:7}{ext}'), 592 | nameOnLoad: true, // force naming on load 593 | )] 594 | #[ORM\Column(type: Document::class, nullable: true)] 595 | public ?Document $image = null; 596 | } 597 | ``` 598 | 599 | > **Note**: this doesn't update the path in the database automatically, see 600 | > [Updating Metadata](#update-metadata) to see how to do this. 601 | 602 | #### Virtual Document Properties 603 | 604 | You can also create `Document` properties on your entities that aren't mapped to the 605 | database. In this case, when you access the property, the `namer` will be called to 606 | generate the path, which will then be loaded from the library. This can be useful 607 | if you have documents that an entity has access to, but are managed elsewhere 608 | (_readonly_ in the context of the entity). As long as they are named in a consistent 609 | manner, you can map to them: 610 | 611 | ```php 612 | use Doctrine\ORM\Mapping as ORM; 613 | use Zenstruck\Document; 614 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 615 | use Zenstruck\Document\Namer\Expression; 616 | 617 | class Part 618 | { 619 | #[ORM\Column] 620 | public string $number; 621 | 622 | #[Mapping( 623 | library: 'public', 624 | namer: new Expression('/part/spec-sheets/{this.number}.pdf'), // use "namer: 'expression:/part/spec-sheets/{this.number}.pdf'" on PHP 8.0 625 | )] 626 | public Document $specSheet; 627 | } 628 | ``` 629 | 630 | > **Note**: the namer for virtual documents will not have [document-related variables](#available-variables) 631 | > available (as it's not yet available). You can only access the object (via `this`). 632 | 633 | If it's possible the document may not exist for every entity, add a setter that 634 | checks for existence before returning: 635 | 636 | ```php 637 | public function getSpecSheet(): ?Document 638 | { 639 | return $this->specSheet->exists() ? $this->specSheet : null; 640 | } 641 | ``` 642 | 643 | ### Document Column as String 644 | 645 | The `Zenstruck\Document` DBAL type stores the document as JSON - even if just storing the 646 | path. This is the most flexible as it allows storing additional metadata later without 647 | needing to migrate the column. If you know you will only ever store the path, you can 648 | use the `document_string` DBAL type. This uses a string column instead of a JSON column. 649 | 650 | ```php 651 | use Doctrine\ORM\Mapping as ORM; 652 | use Zenstruck\Document; 653 | use Zenstruck\Document\Library\Bridge\Doctrine\Persistence\Mapping; 654 | 655 | class User 656 | { 657 | // ... 658 | 659 | #[Mapping(library: 'public')] 660 | #[ORM\Column(type: 'document_string', nullable: true)] 661 | public ?Document $image = null; 662 | 663 | // ... 664 | } 665 | ``` 666 | 667 | > **Warning**: If you ever want to store additional metadata, you will need to run a database 668 | > migration to convert the column from string to JSON. 669 | 670 | ### Serializer 671 | 672 | Serialize/Deserialize document: 673 | 674 | ```php 675 | use Zenstruck\Document; 676 | 677 | /** @var \Symfony\Component\Serializer\Serializer $serializer */ 678 | /** @var Document $document */ 679 | 680 | $json = $serializer->serialize($document, 'json'); // "path/to/document" 681 | 682 | $document = $serializer->deserialize($json, Document::class, 'json', [ 683 | 'library' => 'public', // library name IS REQUIRED when deserializing 684 | ]); // \Zenstruck\Document 685 | ``` 686 | 687 | When a document is a property on an object you want to serialize/deserialize, use the `Context` 688 | attribute to specify the library name: 689 | 690 | ```php 691 | use Symfony\Component\Serializer\Annotation\Context; 692 | use Zenstruck\Document; 693 | 694 | class User 695 | { 696 | #[Context(['library' => 'public'])] 697 | public Document $profileImage; 698 | } 699 | ``` 700 | 701 | #### Serialize Additional Metadata 702 | 703 | You can optionally serialize with additional document metadata: 704 | 705 | ```php 706 | use Zenstruck\Document; 707 | 708 | /** @var \Symfony\Component\Serializer\Serializer $serializer */ 709 | /** @var Document $document */ 710 | 711 | $json = $serializer->serialize($document, 'json', [ 712 | 'metadata' => true, 713 | ]); // {"path": "...", "lastModified": ..., "size": ..., "checksum": "...", "mimeType": "...", "publicUrl": "..."} 714 | 715 | // customize the metadata stored 716 | $json = $serializer->serialize($document, 'json', [ 717 | 'metadata' => ['path', 'size', 'lastModified'] 718 | ]); // {"path": "...", "size": ..., "lastModified": ...} 719 | ``` 720 | 721 | #### Name on Deserialize 722 | 723 | When [serializing with additional metadata](#serialize-additional-metadata), if you don't configure 724 | `path` in your `metadata` array, this triggers lazily generating the document's `path` after 725 | deserializing the document. This can be useful if your backend filesystem structure can change. Since 726 | the path isn't stored in the database, you only have to update the context to _mass-change_ 727 | all serialized document's location. 728 | 729 | ```php 730 | $json = $serializer->serialize($document, 'json', ['metadata' => ['checksum', 'extension']]); // no "path" 731 | 732 | $document = $serializer->deserialize($json, Document::class, 'json', [ 733 | 'library' => 'public', 734 | 'namer' => 'checksum', 735 | ]); // \Zenstruck\Document 736 | 737 | $document->path(); // generated via the namer and the serialized data 738 | ``` 739 | 740 | You can force this behaviour even if the `path` is serialized: 741 | 742 | ```php 743 | $json = $serializer->serialize($document, 'json', ['metadata' => ['path', 'checksum', 'extension']]); // includes path 744 | 745 | $document = $serializer->deserialize($json, Document::class, 'json', [ 746 | 'library' => 'public', 747 | 'namer' => 'checksum', 748 | 'rename' => true, // trigger the rename 749 | ]); // \Zenstruck\Document 750 | 751 | $document->path(); // always generated via the namer 752 | ``` 753 | 754 | #### Doctrine/Serializer Mapping 755 | 756 | If you have an entity with a document that also can be serialized, you can configure 757 | the doctrine mapping and serializer context with just the `Context` attribute to avoid 758 | duplication. 759 | 760 | ```php 761 | use Doctrine\ORM\Mapping as ORM; 762 | use Symfony\Component\Serializer\Annotation\Context; 763 | use Zenstruck\Document; 764 | 765 | class User 766 | { 767 | // ... 768 | 769 | #[Context(['library' => 'public'])] 770 | #[ORM\Column(type: Document::class, nullable: true)] 771 | public ?Document $profileImage = null; 772 | 773 | // ... 774 | } 775 | ``` 776 | --------------------------------------------------------------------------------