diff --git a/composer.json b/composer.json index eb0f1c6..923324d 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "test:ci": "vendor/bin/phpunit -c phpunit.xml tests", "test:ci:coverage": "vendor/bin/phpunit -c phpunit.xml tests --coverage-clover build/logs/clover.xml --coverage-filter src", "test:ci:coverage-check": "php scripts/check-coverage.php build/logs/clover.xml 80", - "phpstan": "vendor/bin/phpstan analyse src", + "phpstan": "vendor/bin/phpstan analyse src --memory-limit=512M", "phpcs": "vendor/bin/phpcs", "phpcbf": "vendor/bin/phpcbf", "psr": "vendor/bin/phpcs", diff --git a/src/Http/README.md b/src/Http/README.md index 31d775c..a2219e1 100644 --- a/src/Http/README.md +++ b/src/Http/README.md @@ -53,6 +53,7 @@ $allHeaders = Request::headers(); // Uploaded files and server values $avatar = Request::file('avatar'); +$rawAvatar = Request::rawFile('avatar'); $remoteAddress = Request::server('REMOTE_ADDR'); ``` @@ -80,10 +81,36 @@ $rawBody = Request::content(); Useful notes: -- `Request::query('key', $default)`, `Request::post('key', $default)`, `Request::input('key', $default)`, `Request::cookie('key', $default)`, `Request::file('key', $default)`, and `Request::header('name', $default)` all support a default value. +- `Request::query('key', $default)`, `Request::post('key', $default)`, `Request::input('key', $default)`, `Request::cookie('key', $default)`, `Request::file('key', $default)`, `Request::rawFile('key', $default)`, and `Request::header('name', $default)` all support a default value. - `Request::input()` and `Request::all()` merge query and POST data. When the same key exists in both places, POST wins. +- `Request::file()` returns `UploadedFile` objects (and nested arrays of `UploadedFile` objects for multi-file inputs), while `Request::rawFile()` returns the original PHP `$_FILES` structure. - `Request::expectsJson()` returns `true` for JSON `Accept` or `Content-Type` headers, AJAX requests, and `/api` routes. +Using the request object directly: + +```php +use Myxa\Http\Request; + +final class UploadController +{ + public function store(Request $request): mixed + { + $avatar = $request->file('avatar'); + $rawAvatar = $request->rawFile('avatar'); + $photos = $request->file('photos', []); + + return [ + 'path' => $request->path(), + 'stored' => $avatar?->store(), + 'raw_name' => $rawAvatar['name'] ?? null, + 'photo_count' => count($photos), + ]; + } +} +``` + +For multi-file inputs, `Request::file('photos')` returns an array of `UploadedFile` objects that you can loop over and store one by one. + ## Response ```php diff --git a/src/Http/Request.php b/src/Http/Request.php index 3dc9e30..84b94dc 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -4,6 +4,8 @@ namespace Myxa\Http; +use Myxa\Storage\UploadedFile; + /** * Lightweight HTTP request object for the current app runtime. * @@ -164,11 +166,29 @@ public function cookie(?string $key = null, mixed $default = null): mixed } /** - * Return an uploaded file entry or the full files array. + * Return an uploaded file wrapper or the full normalized uploaded files map. * - * @return array|mixed + * @return array|UploadedFile|mixed */ public function file(?string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->normalizeUploadedFiles($this->files); + } + + if (!array_key_exists($key, $this->files)) { + return $default; + } + + return $this->normalizeUploadedFileValue($this->files[$key]); + } + + /** + * Return a raw PHP `$_FILES` entry or the full files array. + * + * @return array|mixed + */ + public function rawFile(?string $key = null, mixed $default = null): mixed { return $this->valueFrom($this->files, $key, $default); } @@ -363,6 +383,103 @@ private function valueFrom(array $values, ?string $key, mixed $default): mixed return $values[$key] ?? $default; } + /** + * @param array $files + * @return array + */ + private function normalizeUploadedFiles(array $files): array + { + $normalized = []; + + foreach ($files as $key => $value) { + $normalized[$key] = $this->normalizeUploadedFileValue($value); + } + + return $normalized; + } + + private function normalizeUploadedFileValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + if ($this->isUploadedFileArray($value)) { + if ($this->hasNestedUploadedFiles($value)) { + return $this->normalizeNestedUploadedFiles($value); + } + + return UploadedFile::fromArray($value); + } + + $normalized = []; + + foreach ($value as $key => $nestedValue) { + $normalized[$key] = $this->normalizeUploadedFileValue($nestedValue); + } + + return $normalized; + } + + /** + * @param array{name: mixed, type: mixed, size: mixed, tmp_name: mixed, error: mixed} $fileData + * @return array + */ + private function normalizeNestedUploadedFiles(array $fileData): array + { + $keys = []; + + foreach (['name', 'type', 'size', 'tmp_name', 'error'] as $attribute) { + if (!is_array($fileData[$attribute])) { + continue; + } + + foreach (array_keys($fileData[$attribute]) as $key) { + $keys[$key] = true; + } + } + + $normalized = []; + + foreach (array_keys($keys) as $key) { + $normalized[$key] = $this->normalizeUploadedFileValue([ + 'name' => is_array($fileData['name']) ? ($fileData['name'][$key] ?? null) : null, + 'type' => is_array($fileData['type']) ? ($fileData['type'][$key] ?? null) : null, + 'size' => is_array($fileData['size']) ? ($fileData['size'][$key] ?? null) : null, + 'tmp_name' => is_array($fileData['tmp_name']) ? ($fileData['tmp_name'][$key] ?? null) : null, + 'error' => is_array($fileData['error']) ? ($fileData['error'][$key] ?? null) : null, + ]); + } + + return $normalized; + } + + /** + * @param array $value + */ + private function isUploadedFileArray(array $value): bool + { + $requiredKeys = ['error', 'name', 'size', 'tmp_name', 'type']; + + sort($requiredKeys); + $keys = array_keys($value); + sort($keys); + + return $keys === $requiredKeys; + } + + /** + * @param array{name: mixed, type: mixed, size: mixed, tmp_name: mixed, error: mixed} $value + */ + private function hasNestedUploadedFiles(array $value): bool + { + return is_array($value['name']) + || is_array($value['type']) + || is_array($value['size']) + || is_array($value['tmp_name']) + || is_array($value['error']); + } + /** * @param array $query */ diff --git a/src/Storage/README.md b/src/Storage/README.md index 455dff8..e660e6d 100644 --- a/src/Storage/README.md +++ b/src/Storage/README.md @@ -58,6 +58,61 @@ $exists = Storage::exists('avatars/john.txt'); ## Upload Files +From a request upload object with an injected request: + +```php +use Myxa\Http\Request; + +final class ProfileController +{ + public function store(Request $request): void + { + $stored = $request->file('avatar')->store(); + + $stored = $request->file('avatar')->store('avatars/john.png', [ + 'metadata' => ['user_id' => 42], + ]); + } +} +``` + +Storing multiple uploaded files: + +```php +use Myxa\Http\Request; + +final class GalleryController +{ + public function store(Request $request): array + { + $storedFiles = []; + + foreach ($request->file('photos', []) as $index => $photo) { + $storedFiles[] = $photo->store(sprintf('galleries/photo-%d.%s', $index + 1, $photo->extension())); + } + + return $storedFiles; + } +} +``` + +From a request upload object with the facade: + +```php +use Myxa\Support\Facades\Request; + +$stored = Request::file('avatar')->store(); + +$stored = Request::file('avatar')->store('avatars/john.png', [ + 'metadata' => ['user_id' => 42], +]); + +$stored = Request::file('avatar')->store( + 'avatars/john.png', + storage: 's3', +); +``` + With the facade: ```php @@ -81,6 +136,9 @@ $stored = $storage->upload( ); ``` +`UploadedFile::store()` is the preferred path when you already have a request file object. +`Storage::upload()` remains available as a convenience wrapper for raw `$_FILES` data or facade-style usage. + ## Named Drivers You can register multiple storage drivers and target them by alias. diff --git a/src/Storage/UploadedFile.php b/src/Storage/UploadedFile.php index 32958b6..1965b47 100644 --- a/src/Storage/UploadedFile.php +++ b/src/Storage/UploadedFile.php @@ -4,7 +4,9 @@ namespace Myxa\Storage; +use InvalidArgumentException; use Myxa\Storage\Exceptions\StorageException; +use Myxa\Support\Facades\Storage as StorageFacade; final class UploadedFile { @@ -193,24 +195,34 @@ public function contents(bool $base64Encode = false): string } /** - * Persist the upload into a storage driver. + * Persist the upload into storage. + * + * Supports both styles: + * - store('avatars/photo.png', ['metadata' => [...]]) + * - store($storage, 'avatars/photo.png', ['metadata' => [...]]) * * @param array{name?: string, mime_type?: string, metadata?: array} $options */ - public function store(StorageInterface $storage, ?string $location = null, array $options = []): StoredFile + public function store(mixed $location = null, mixed $options = [], mixed $storage = null): StoredFile { if (!$this->isValid()) { throw new StorageException($this->errorMessage()); } - $resolvedLocation = $location ?? self::sanitizeFileName($this->name); + [$resolvedStorage, $resolvedLocation, $resolvedOptions] = $this->resolveStoreArguments( + $location, + $options, + $storage, + ); + + $resolvedLocation ??= self::sanitizeFileName($this->name); $resolvedOptions = [ - 'name' => $options['name'] ?? $this->name, - 'mime_type' => $options['mime_type'] ?? $this->type, - 'metadata' => $options['metadata'] ?? [], + 'name' => $resolvedOptions['name'] ?? $this->name, + 'mime_type' => $resolvedOptions['mime_type'] ?? $this->type, + 'metadata' => $resolvedOptions['metadata'] ?? [], ]; - return $storage->put($resolvedLocation, $this->contents(), $resolvedOptions); + return $resolvedStorage->put($resolvedLocation, $this->contents(), $resolvedOptions); } /** @@ -226,4 +238,74 @@ private function isValidFileArray(array $fileData): bool return $keys === $requiredKeys; } + + /** + * @return array{0: StorageInterface, 1: ?string, 2: array} + */ + private function resolveStoreArguments(mixed $location, mixed $options, mixed $storage): array + { + if ($location instanceof StorageInterface) { + if (is_array($options) && $storage === null) { + return [$location, null, $options]; + } + + return [ + $location, + $this->normalizeNullableLocation($options), + $this->normalizeStoreOptions($storage), + ]; + } + + return [ + $this->resolveStorage($storage), + $this->normalizeNullableLocation($location), + $this->normalizeStoreOptions($options), + ]; + } + + private function normalizeNullableLocation(mixed $location): ?string + { + if ($location === null) { + return null; + } + + if (!is_string($location)) { + throw new InvalidArgumentException('Upload location must be a string or null.'); + } + + return $location; + } + + /** + * @return array + */ + private function normalizeStoreOptions(mixed $options): array + { + if ($options === null) { + return []; + } + + if (!is_array($options)) { + throw new InvalidArgumentException('Upload options must be an array.'); + } + + return $options; + } + + private function resolveStorage(mixed $storage): StorageInterface + { + if ($storage instanceof StorageInterface) { + return $storage; + } + + if ($storage === null) { + return StorageFacade::storage(); + } + + if (!is_string($storage)) { + throw new InvalidArgumentException('Upload storage must be a storage alias, storage instance, or null.'); + } + + return StorageFacade::storage($storage); + } } diff --git a/src/Support/Facades/Request.php b/src/Support/Facades/Request.php index 22cd152..1865db5 100644 --- a/src/Support/Facades/Request.php +++ b/src/Support/Facades/Request.php @@ -97,13 +97,21 @@ public static function cookie(?string $key = null, mixed $default = null): mixed } /** - * Return an uploaded file entry or the full files array. + * Return an uploaded file wrapper or the full normalized uploaded files map. */ public static function file(?string $key = null, mixed $default = null): mixed { return self::getRequest()->file($key, $default); } + /** + * Return a raw PHP `$_FILES` entry or the full files array. + */ + public static function rawFile(?string $key = null, mixed $default = null): mixed + { + return self::getRequest()->rawFile($key, $default); + } + /** * Return a server value or the full server array. */ diff --git a/tests/Unit/Http/RequestFacadeTest.php b/tests/Unit/Http/RequestFacadeTest.php index 814db7d..aa69ff9 100644 --- a/tests/Unit/Http/RequestFacadeTest.php +++ b/tests/Unit/Http/RequestFacadeTest.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Myxa\Http\Request as HttpRequest; +use Myxa\Storage\UploadedFile; use Myxa\Support\Facades\Request as RequestFacade; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -25,7 +26,13 @@ public function testFacadeDelegatesToCurrentRequestInstance(): void query: ['page' => '3'], post: ['name' => 'Myxa'], cookies: ['theme' => 'forest'], - files: ['avatar' => ['name' => 'avatar.png']], + files: ['avatar' => [ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ]], server: [ 'REQUEST_METHOD' => 'PATCH', 'REQUEST_URI' => '/profile?page=3', @@ -51,7 +58,15 @@ public function testFacadeDelegatesToCurrentRequestInstance(): void self::assertSame('Myxa', RequestFacade::input('name')); self::assertSame(['page' => '3', 'name' => 'Myxa'], RequestFacade::all()); self::assertSame('forest', RequestFacade::cookie('theme')); - self::assertSame(['name' => 'avatar.png'], RequestFacade::file('avatar')); + self::assertInstanceOf(UploadedFile::class, RequestFacade::file('avatar')); + self::assertSame('avatar.png', RequestFacade::file('avatar')->name()); + self::assertSame([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ], RequestFacade::rawFile('avatar')); self::assertSame('PATCH', RequestFacade::server('REQUEST_METHOD')); self::assertSame('application/json', RequestFacade::header('content-type')); self::assertSame([ diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index f42998b..c665328 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -5,6 +5,7 @@ namespace Test\Unit\Http; use Myxa\Http\Request; +use Myxa\Storage\UploadedFile; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -17,7 +18,13 @@ public function testRequestNormalizesHttpStateAndExposesHelpers(): void query: ['page' => '2'], post: ['search' => 'myxa'], cookies: ['session' => 'abc123'], - files: ['avatar' => ['name' => 'avatar.png']], + files: ['avatar' => [ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ]], server: [ 'REQUEST_METHOD' => 'post', 'REQUEST_URI' => '/users/list?page=2', @@ -40,7 +47,16 @@ public function testRequestNormalizesHttpStateAndExposesHelpers(): void self::assertSame('2', $request->input('page')); self::assertSame(['page' => '2', 'search' => 'myxa'], $request->all()); self::assertSame('abc123', $request->cookie('session')); - self::assertSame(['name' => 'avatar.png'], $request->file('avatar')); + self::assertInstanceOf(UploadedFile::class, $request->file('avatar')); + self::assertSame('avatar.png', $request->file('avatar')->name()); + self::assertSame('/tmp/avatar.png', $request->file('avatar')->tempPath()); + self::assertSame([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ], $request->rawFile('avatar')); self::assertSame('application/json', $request->header('content-type')); self::assertSame('XMLHttpRequest', $request->header('X-Requested-With')); self::assertTrue($request->ajax()); @@ -104,7 +120,13 @@ public function testRequestReturnsFullCollectionsAndFormattedHeaders(): void query: ['page' => '1'], post: ['name' => 'Myxa'], cookies: ['theme' => 'forest'], - files: ['avatar' => ['name' => 'avatar.png']], + files: ['avatar' => [ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ]], server: [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/profile', @@ -120,10 +142,19 @@ public function testRequestReturnsFullCollectionsAndFormattedHeaders(): void self::assertSame(['page' => '1'], $request->query()); self::assertSame(['name' => 'Myxa'], $request->post()); self::assertSame(['theme' => 'forest'], $request->cookie()); - self::assertSame(['avatar' => ['name' => 'avatar.png']], $request->file()); + self::assertInstanceOf(UploadedFile::class, $request->file()['avatar']); + self::assertSame('avatar.png', $request->file()['avatar']->name()); + self::assertSame(['avatar' => [ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 123, + 'tmp_name' => '/tmp/avatar.png', + 'error' => 0, + ]], $request->rawFile()); self::assertSame('forest', $request->cookie('theme')); self::assertSame('fallback', $request->cookie('missing', 'fallback')); self::assertSame('fallback', $request->file('missing', 'fallback')); + self::assertSame('fallback', $request->rawFile('missing', 'fallback')); self::assertSame('192.168.1.10', $request->server('REMOTE_ADDR')); self::assertSame([ 'Accept-Language' => 'en-US', @@ -137,6 +168,35 @@ public function testRequestReturnsFullCollectionsAndFormattedHeaders(): void self::assertSame(443, $request->port()); } + public function testRequestNormalizesNestedUploadedFiles(): void + { + $request = new Request(files: [ + 'photos' => [ + 'name' => ['cover.jpg', 'gallery.png'], + 'type' => ['image/jpeg', 'image/png'], + 'size' => [100, 200], + 'tmp_name' => ['/tmp/cover.jpg', '/tmp/gallery.png'], + 'error' => [0, 0], + ], + ]); + + $photos = $request->file('photos'); + + self::assertIsArray($photos); + self::assertCount(2, $photos); + self::assertInstanceOf(UploadedFile::class, $photos[0]); + self::assertInstanceOf(UploadedFile::class, $photos[1]); + self::assertSame('cover.jpg', $photos[0]->name()); + self::assertSame('gallery.png', $photos[1]->name()); + self::assertSame([ + 'name' => ['cover.jpg', 'gallery.png'], + 'type' => ['image/jpeg', 'image/png'], + 'size' => [100, 200], + 'tmp_name' => ['/tmp/cover.jpg', '/tmp/gallery.png'], + 'error' => [0, 0], + ], $request->rawFile('photos')); + } + public function testRequestExtractsBearerTokenWhenAuthorizationHeaderIsPresent(): void { $request = new Request(server: [ diff --git a/tests/Unit/Support/Storage/UploadedFileTest.php b/tests/Unit/Support/Storage/UploadedFileTest.php index 9270474..a10c142 100644 --- a/tests/Unit/Support/Storage/UploadedFileTest.php +++ b/tests/Unit/Support/Storage/UploadedFileTest.php @@ -4,8 +4,10 @@ namespace Test\Unit\Support\Storage; +use Myxa\Support\Facades\Storage as StorageFacade; use Myxa\Storage\Local\LocalStorage; use Myxa\Storage\Exceptions\StorageException; +use Myxa\Storage\StorageManager; use Myxa\Storage\UploadedFile; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -19,6 +21,9 @@ final class UploadedFileTest extends TestCase private string $storageRoot; + /** @var list */ + private array $storageRoots = []; + protected function setUp(): void { $this->tempFile = tempnam(sys_get_temp_dir(), 'myxa-upload-'); @@ -29,25 +34,19 @@ protected function setUp(): void } file_put_contents($this->tempFile, 'avatar-bytes'); + $this->storageRoots = [$this->storageRoot]; } protected function tearDown(): void { + StorageFacade::clearManager(); + if (is_file($this->tempFile)) { unlink($this->tempFile); } - if (is_dir($this->storageRoot)) { - $items = scandir($this->storageRoot) ?: []; - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - unlink($this->storageRoot . '/' . $item); - } - - rmdir($this->storageRoot); + foreach ($this->storageRoots as $storageRoot) { + $this->deleteDirectory($storageRoot); } } @@ -74,6 +73,59 @@ public function testUploadedFileReadsContentAndStoresViaStorage(): void self::assertSame('avatar-bytes', $storage->read('My_Avatar.png')); } + public function testUploadedFileStoresThroughDefaultFacadeStorageWhenNoStorageIsPassed(): void + { + $manager = new StorageManager('local'); + $storage = new LocalStorage($this->storageRoot); + $manager->addStorage('local', $storage); + StorageFacade::setManager($manager); + + $upload = UploadedFile::fromArray([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 12, + 'tmp_name' => $this->tempFile, + 'error' => 0, + ]); + + $stored = $upload->store(); + + self::assertSame('local', $stored->storage()); + self::assertSame('avatar.png', $stored->location()); + self::assertSame('avatar-bytes', $storage->read('avatar.png')); + } + + public function testUploadedFileStoresThroughNamedFacadeStorageAndMetadata(): void + { + $localRoot = $this->storageRoot . '-local'; + $remoteRoot = $this->storageRoot . '-remote'; + $this->storageRoots[] = $localRoot; + $this->storageRoots[] = $remoteRoot; + + $manager = new StorageManager('local'); + $manager->addStorage('local', new LocalStorage($localRoot)); + $manager->addStorage('remote', new LocalStorage($remoteRoot, 'remote')); + StorageFacade::setManager($manager); + + $upload = UploadedFile::fromArray([ + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 12, + 'tmp_name' => $this->tempFile, + 'error' => 0, + ]); + + $stored = $upload->store( + 'profiles/me.png', + ['metadata' => ['owner' => 'user-1']], + 'remote', + ); + + self::assertSame('remote', $stored->storage()); + self::assertSame('profiles/me.png', $stored->location()); + self::assertSame('user-1', $stored->metadata('owner')); + } + public function testUploadedFileRejectsExtensionMismatch(): void { $upload = UploadedFile::fromArray([ @@ -159,4 +211,30 @@ public function testUploadedFileReportsPhpErrorsAndReadFailures(): void $invalidRead->contents(); } + + private function deleteDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory) ?: []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $directory . '/' . $item; + + if (is_dir($path)) { + $this->deleteDirectory($path); + + continue; + } + + unlink($path); + } + + rmdir($directory); + } }