From 3e8abce4d89a89e9d8cee8387f7a26e8fa8c7112 Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Fri, 3 Apr 2026 15:11:35 +0200 Subject: [PATCH 1/5] chore: sync GitHub issue templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bc5e177..c8596fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,8 +29,8 @@ We're sorry to hear you have a problem. Can you help us solve it by providing th attributes: label: PHP Version description: What version of PHP are you running? Please be as specific as possible - placeholder: "8.4.0" - value: "8.4.0" + placeholder: "8.5.0" + value: "8.5.0" validations: required: true - type: input @@ -38,8 +38,8 @@ We're sorry to hear you have a problem. Can you help us solve it by providing th attributes: label: Laravel Version description: What version of Laravel are you running? Please be as specific as possible - placeholder: "12.0.0" - value: "12.0.0" + placeholder: "13.0.0" + value: "13.0.0" validations: required: true - type: dropdown From 1243dc76834e859663d00ac3869e5a4af8ed615f Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Fri, 3 Apr 2026 15:11:39 +0200 Subject: [PATCH 2/5] chore: sync Laravel Pint workflow from template --- .github/workflows/fix-php-code-style-issues.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 41e6193..1416546 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -13,8 +13,17 @@ jobs: - name: Checkout code uses: actions/checkout@v6.0.2 - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.6 + - name: Setup PHP + uses: shivammathur/setup-php@2.37.0 + with: + php-version: '8.5' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@4.0.0 + + - name: Run Laravel Pint + run: vendor/bin/pint - name: Commit changes uses: stefanzweifel/git-auto-commit-action@v7.1.0 From 1639a56ebaeeff35e9a5d9c148449d51a59708cb Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Fri, 3 Apr 2026 15:11:44 +0200 Subject: [PATCH 3/5] chore: sync PHPStan workflow from template --- .github/workflows/phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 1802312..5bbb64c 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -32,7 +32,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@2.37.0 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Install composer dependencies From 5797303e4390ed8714d17162bb42ae377e157008 Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Fri, 3 Apr 2026 15:11:46 +0200 Subject: [PATCH 4/5] chore: sync dependency audit workflows from template --- .github/workflows/composer-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/composer-audit.yml b/.github/workflows/composer-audit.yml index ed5c547..72698cf 100644 --- a/.github/workflows/composer-audit.yml +++ b/.github/workflows/composer-audit.yml @@ -28,7 +28,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@2.37.0 with: - php-version: '8.4' + php-version: '8.5' coverage: none - name: Resolve dependencies and audit From 25ee9d56cd5807d8863ee9f45ac067903dee2e20 Mon Sep 17 00:00:00 2001 From: Sebastian Fix Date: Fri, 3 Apr 2026 15:39:48 +0200 Subject: [PATCH 5/5] Update Laravel 13 + PHP 8.5 --- .github/workflows/run-tests.yml | 10 +- CHANGELOG.md | 14 ++ README.md | 15 +- composer.json | 29 ++- phpstan.neon.dist | 5 + phpunit.xml.dist | 2 +- src/FlysystemCloudinaryAdapter.php | 323 +++++++++++++++++---------- tests/Feature/AdapterTest.php | 34 ++- tests/Integration/CloudinaryTest.php | 34 +-- tests/Pest.php | 2 + 10 files changed, 307 insertions(+), 161 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index aaa34ea..85cdcad 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,18 +14,18 @@ jobs: max-parallel: 1 matrix: os: [ ubuntu-latest ] - php: [ 8.2, 8.3, 8.4 ] - laravel: [ 12.* ] + php: [ 8.3, 8.4, 8.5 ] + laravel: [ 13.* ] stability: [ prefer-lowest, prefer-stable ] name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@2.37.0 with: php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo @@ -45,7 +45,7 @@ jobs: run: cp phpunit.xml.dist phpunit.xml - name: Execute tests - run: vendor/bin/pest + run: vendor/bin/pest --no-coverage env: CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4f415..4e1a81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to `laravel-cloudinary` will be documented in this file. +## 13.0.0 - 2026-04-03 + +- Laravel 13 support +- PHP 8.3, 8.4, and 8.5 support (PHP 8.2 dropped) +- Development tooling: Orchestra Testbench 11, Pest 4, Larastan 3.9 +- Packagist `dev-main` branch alias `13.x-dev` for Composer until `v13.0.0` is tagged +- **Flysystem 3:** `listContents()` now returns `FileAttributes` / `DirectoryAttributes` so `Storage::files()`, `Storage::directories()`, and related APIs work (fixes [#64](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/64), [#80](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/80)) +- **Flysystem 3:** `read()` / `readStream()` throw `UnableToReadFile` on failure; `readStream()` returns a `resource`; `copy()` throws `UnableToCopyFile`; `delete()` throws `UnableToDeleteFile` when destroy fails; `createDirectory()` / `deleteDirectory()` throw the corresponding Flysystem exceptions +- Apply configured folder prefix to `move()`, `createDirectory()`, `deleteDirectory()`, and fix `directoryExists()` for root-level paths +- Fix string uploads: rewind temp stream after `fwrite()` before Cloudinary `upload()` +- `createDir()` catches `ApiError` as well as `RateLimited` +- Composer scripts: `analyse` (PHPStan), `test` / `test-coverage` via Pest with `--exclude-group=integration`; `format` uses Pint +- Integration tests: `integration` group, skip when placeholder Cloudinary env is used + ## 2.0.0 - 2022-11-20 laravel-flysystem v3 upgrade: diff --git a/README.md b/README.md index 6fb394d..5a733f1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ additional parameters to your url 😉 | Package | PHP | Laravel | Flysystem | |-----------|-------------|-----------|-------------| -| v12.0 | ^8.2 - ^8.4 | 12.x | 3.25.1 | +| v13.0 | 8.3.*–8.5.* | 13.x | 3.x | +| v12.0 | ^8.2 - ^8.4 | 12.x | 3.x | | v11.0 | ^8.2 - ^8.3 | 11.x | 3.0 | | v4.0 | ^8.2 - ^8.3 | 11.x | 3.0 | | v3.0 | 8.2 | 10.x | 3.0 | @@ -59,13 +60,23 @@ configuration: Add the following environment variables to your `.env` file: ```shell -FILESYSTEM_DRIVER=cloudinary +FILESYSTEM_DISK=cloudinary CLOUDINARY_CLOUD_NAME=my-cloud-name CLOUDINARY_API_KEY=my-api-key CLOUDINARY_API_SECRET=my-api-secret ``` +Older Laravel apps may still use `FILESYSTEM_DRIVER`; Laravel 9+ prefers `FILESYSTEM_DISK`. + +### Cloudinary folder modes + +This adapter lists assets with the Admin API using **public ID `prefix`** and manages folders with **`subFolders` / `create_folder` / `delete_folder`**, which matches **legacy fixed folder mode** and typical public-ID paths. If your Cloudinary product environment uses **dynamic folder mode** only, some behaviours may differ; see [Folder modes](https://cloudinary.com/documentation/folder_modes) and the [Admin API](https://cloudinary.com/documentation/admin_api#folders). + +### Continuous integration and integration tests + +The test suite includes optional **integration** tests that call the live Cloudinary API. They run only when `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` are set to real values (for example via GitHub Actions secrets). The default `composer test` command excludes the `integration` group; run `vendor/bin/pest` without `--exclude-group` to include them locally. + ## 🏗 File extension problem Let's look at the following example: diff --git a/composer.json b/composer.json index 0f073eb..4261b8c 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "codebar-ag/laravel-flysystem-cloudinary", - "description": "Cloudinary Flysystem v1 integration with Laravel", + "description": "Cloudinary Flysystem 3 integration with Laravel", "keywords": [ "laravel", "codebar-ag", @@ -19,18 +19,18 @@ } ], "require": { - "php": "8.2.*|8.3.*|8.4.*", + "php": "8.3.*|8.4.*|8.5.*", "guzzlehttp/guzzle": "^7.8", - "illuminate/contracts": "^12.0", + "illuminate/contracts": "^13.0", "cloudinary/cloudinary_php": "^2.13", "nesbot/carbon": "^3.8", "spatie/laravel-package-tools": "^1.19" }, "require-dev": { "laravel/pint": "^1.21", - "larastan/larastan": "^v3.1", - "orchestra/testbench": "^10.0", - "pestphp/pest": "^3.7", + "larastan/larastan": "^3.9", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -47,25 +47,30 @@ } }, "scripts": { - "psalm": "vendor/bin/psalm", - "test": "./vendor/bin/testbench package:test --parallel --no-coverage --exclude-group=Integration", - "test-coverage": "vendor/bin/phpunit --coverage-html coverage --exclude-group=Integration", - "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest --no-coverage --exclude-group=integration", + "test-coverage": "vendor/bin/pest --coverage --exclude-group=integration", + "format": "vendor/bin/pint" }, "config": { "sort-packages": true, "allow-plugins": { + "composer/package-versions-deprecated": false, "pestphp/pest-plugin": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true } }, "extra": { + "branch-alias": { + "dev-main": "13.x-dev" + }, "laravel": { "providers": [ "CodebarAg\\FlysystemCloudinary\\FlysystemCloudinaryServiceProvider" ] } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0cc2d04..8db6c0b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,3 +10,8 @@ parameters: checkOctaneCompatibility: true checkModelProperties: true noEnvCallsOutsideOfConfig: false + ignoreErrors: + - + identifier: argument.type + message: '#Parameter \#1 \$file of method Cloudinary\\Api\\Upload\\UploadApi::upload\(\) expects string#' + path: src/FlysystemCloudinaryAdapter.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e36c338..9730646 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,7 +21,7 @@ - + diff --git a/src/FlysystemCloudinaryAdapter.php b/src/FlysystemCloudinaryAdapter.php index f7bb77c..a9bc5b6 100644 --- a/src/FlysystemCloudinaryAdapter.php +++ b/src/FlysystemCloudinaryAdapter.php @@ -7,7 +7,6 @@ use Cloudinary\Api\Exception\BadRequest; use Cloudinary\Api\Exception\NotFound; use Cloudinary\Api\Exception\RateLimited; -use Cloudinary\Asset\Media; use Cloudinary\Cloudinary; use Cloudinary\Configuration\Configuration; use CodebarAg\FlysystemCloudinary\Events\FlysystemCloudinaryResponseLog; @@ -17,11 +16,18 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use League\Flysystem\Config; +use League\Flysystem\DirectoryAttributes; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemAdapter; +use League\Flysystem\UnableToCopyFile; +use League\Flysystem\UnableToCreateDirectory; +use League\Flysystem\UnableToDeleteDirectory; +use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; use League\Flysystem\UnableToSetVisibility; +use League\Flysystem\UnableToWriteFile; use League\MimeTypeDetection\FinfoMimeTypeDetector; use Throwable; @@ -54,6 +60,9 @@ public function __construct( public function write(string $path, string $contents, Config $config): void { $this->meta = $this->upload($path, $contents, $config); + if ($this->meta === false) { + throw UnableToWriteFile::atLocation($path, 'Upload failed.'); + } } /** @@ -62,6 +71,9 @@ public function write(string $path, string $contents, Config $config): void public function writeStream(string $path, $resource, Config $config): void { $this->meta = $this->upload($path, $resource, $config); + if ($this->meta === false) { + throw UnableToWriteFile::atLocation($path, 'Upload failed.'); + } } /** @@ -89,12 +101,18 @@ public function updateStream($path, $resource, Config $config): array|false */ protected function upload(string $path, $body, Config $config): array|false { + $tempFile = null; if (is_string($body)) { $tempFile = tmpfile(); - + if ($tempFile === false) { + return false; + } if (fwrite($tempFile, $body) === false) { return false; } + if (rewind($tempFile) === false) { + return false; + } } $path = trim($path, '/'); @@ -172,24 +190,23 @@ public function rename($path, $newpath): bool */ public function copy(string $path, string $newpath, Config $config): void { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $newpath = $this->ensureFolderIsPrefixed(trim($newpath, '/')); + $sourceLogical = trim($path, '/'); + $destLogical = trim($newpath, '/'); + $prefixedSource = $this->ensureFolderIsPrefixed($sourceLogical); + $prefixedDest = $this->ensureFolderIsPrefixed($destLogical); - $metaRead = $this->readObject($path); + $metaRead = $this->readObject($prefixedSource); if ($metaRead === false) { $this->copied = false; - - return; + throw UnableToCopyFile::fromLocationTo($sourceLogical, $destLogical); } - $metaUpload = $this->upload($newpath, $metaRead['contents'], $config); + $metaUpload = $this->upload($prefixedDest, $metaRead['contents'], $config); if ($metaUpload === false) { $this->copied = false; - - return; + throw UnableToCopyFile::fromLocationTo($sourceLogical, $destLogical); } $this->copied = true; @@ -202,9 +219,16 @@ public function copy(string $path, string $newpath, Config $config): void */ public function delete(string $path): void { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $logical = trim($path, '/'); + $prefixed = $this->ensureFolderIsPrefixed($logical); - $this->deleted = $this->destroy($path); + $this->deleted = $this->destroy($prefixed); + if (! $this->deleted) { + throw UnableToDeleteFile::atLocation( + $logical, + 'Cloudinary destroy did not succeed for this resource type or path.' + ); + } } protected function destroy(string $path): bool @@ -213,42 +237,42 @@ protected function destroy(string $path): bool 'invalidate' => true, ]; - $options['resource_type'] = 'image'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); - event(new FlysystemCloudinaryResponseLog($response)); + try { + $options['resource_type'] = 'image'; + $response = $this + ->cloudinary + ->uploadApi() + ->destroy($path, $options); + event(new FlysystemCloudinaryResponseLog($response)); - if ($response->getArrayCopy()['result'] === 'ok') { - return true; - } + if ($response->getArrayCopy()['result'] === 'ok') { + return true; + } - $options['resource_type'] = 'raw'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); + $options['resource_type'] = 'raw'; + $response = $this + ->cloudinary + ->uploadApi() + ->destroy($path, $options); - event(new FlysystemCloudinaryResponseLog($response)); + event(new FlysystemCloudinaryResponseLog($response)); - if ($response->getArrayCopy()['result'] === 'ok') { - return true; - } + if ($response->getArrayCopy()['result'] === 'ok') { + return true; + } - $options['resource_type'] = 'video'; - $response = $this - ->cloudinary - ->uploadApi() - ->destroy($path, $options); + $options['resource_type'] = 'video'; + $response = $this + ->cloudinary + ->uploadApi() + ->destroy($path, $options); - event(new FlysystemCloudinaryResponseLog($response)); + event(new FlysystemCloudinaryResponseLog($response)); - if ($response->getArrayCopy()['result'] === 'ok') { - return true; + return $response->getArrayCopy()['result'] === 'ok'; + } catch (ApiError) { + return false; } - - return false; } /** @@ -258,12 +282,14 @@ public function deleteDir($dirname): bool { $dirname = $this->ensureFolderIsPrefixed(trim($dirname, '/')); - $files = $this->listContents($dirname); + $files = $this->listContents($dirname, false); - foreach ($files as ['path' => $path]) { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); - - $this->destroy($path); + foreach ($files as $item) { + if (! $item->isFile()) { + continue; + } + $logical = $item->path(); + $this->destroy($this->ensureFolderIsPrefixed($logical)); } try { @@ -285,33 +311,39 @@ public function deleteDir($dirname): bool */ public function createDir($dirname, Config $config): array|false { - $dirname = $this->ensureFolderIsPrefixed(trim($dirname, '/')); + $logical = trim($dirname, '/'); + $prefixed = $this->ensureFolderIsPrefixed($logical); try { $response = $this ->cloudinary ->adminApi() - ->createFolder($dirname); - } catch (RateLimited) { + ->createFolder($prefixed); + } catch (ApiError|RateLimited) { return false; } event(new FlysystemCloudinaryResponseLog($response)); return [ - 'path' => $dirname, + 'path' => $logical, 'type' => 'dir', ]; } public function move(string $source, string $destination, Config $config): void { + $sourceLogical = trim($source, '/'); + $destLogical = trim($destination, '/'); + $prefixedSource = $this->ensureFolderIsPrefixed($sourceLogical); + $prefixedDest = $this->ensureFolderIsPrefixed($destLogical); + try { $this->cloudinary ->uploadApi() - ->rename($source, $destination); + ->rename($prefixedSource, $prefixedDest); } catch (NotFound $exception) { - throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); + throw UnableToMoveFile::fromLocationTo($sourceLogical, $destLogical, $exception); } } @@ -338,45 +370,45 @@ public function has($path): array|bool|null */ public function read(string $path): string { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $logical = trim($path, '/'); + $prefixed = $this->ensureFolderIsPrefixed($logical); - try { - $contents = file_get_contents(Media::fromParams($path)); - } catch (Exception) { - $contents = ''; + $meta = $this->readObject($prefixed); + if ($meta === false) { + throw UnableToReadFile::fromLocation($path); } - return (string) $contents; + return (string) $meta['contents']; } /** * {@inheritDoc} */ - public function readStream($path): array|false /** @phpstan-ignore-line */ + public function readStream(string $path) { - $path = $this->ensureFolderIsPrefixed(trim($path, '/')); + $logical = trim($path, '/'); + $prefixed = $this->ensureFolderIsPrefixed($logical); - $meta = $this->readObject($path); + $meta = $this->readObject($prefixed); if ($meta === false) { - return false; + throw UnableToReadFile::fromLocation($path); } $tempFile = tmpfile(); + if ($tempFile === false) { + throw UnableToReadFile::fromLocation($path, 'Could not create temporary stream.'); + } if (fwrite($tempFile, $meta['contents']) === false) { - return false; + throw UnableToReadFile::fromLocation($path); } if (rewind($tempFile) === false) { - return false; + throw UnableToReadFile::fromLocation($path); } - unset($meta['contents']); - - $meta['stream'] = $tempFile; - - return $meta; + return $tempFile; } /** @@ -405,10 +437,14 @@ protected function readObject(string $path): array|bool /** * {@inheritDoc} + * + * Shallow listing only ($deep is ignored). Cloudinary Admin API does not map cleanly to recursive Flysystem trees. + * + * @return iterable */ - public function listContents($directory = '', $recursive = false): array + public function listContents(string $path, bool $deep): iterable { - $directory = $this->ensureFolderIsPrefixed(trim($directory, '/')); + $directory = $this->ensureFolderIsPrefixed(trim($path, '/')); $options = [ 'type' => 'upload', @@ -448,40 +484,69 @@ public function listContents($directory = '', $recursive = false): array event(new FlysystemCloudinaryResponseLog($responseVideoFiles)); event(new FlysystemCloudinaryResponseLog($responseDirectories)); - $rawFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseRawFiles->getArrayCopy()['resources']); + $out = []; - $imageFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseImageFiles->getArrayCopy()['resources']); + foreach ($responseRawFiles->getArrayCopy()['resources'] ?? [] as $resource) { + $out[] = $this->toFileAttributes( + $this->normalizeResponse($resource, $resource['public_id']) + ); + } - $videoFiles = array_map(function (array $resource) { - return $this->normalizeResponse($resource, $resource['public_id']); - }, $responseVideoFiles->getArrayCopy()['resources']); + foreach ($responseImageFiles->getArrayCopy()['resources'] ?? [] as $resource) { + $out[] = $this->toFileAttributes( + $this->normalizeResponse($resource, $resource['public_id']) + ); + } - $folders = array_map(function (array $resource) { - $path = $this->ensurePrefixedFolderIsRemoved($resource['path']); + foreach ($responseVideoFiles->getArrayCopy()['resources'] ?? [] as $resource) { + $out[] = $this->toFileAttributes( + $this->normalizeResponse($resource, $resource['public_id']) + ); + } - return [ - 'type' => 'dir', - 'path' => $path, - 'name' => $resource['name'], - ]; - }, $responseDirectories->getArrayCopy()['folders']); + foreach ($responseDirectories->getArrayCopy()['folders'] ?? [] as $resource) { + $logicalPath = $this->ensurePrefixedFolderIsRemoved($resource['path']); - return [ - ...$rawFiles, - ...$imageFiles, - ...$videoFiles, - ...$folders, - ]; + $out[] = new DirectoryAttributes( + $logicalPath, + null, + null, + isset($resource['name']) ? ['name' => $resource['name']] : [] + ); + } + + return $out; + } + + private function toFileAttributes(array $normalized): FileAttributes + { + $timestamp = $normalized['timestamp']; + $lastModified = ($timestamp !== false && $timestamp !== null) ? (int) $timestamp : null; + + $extra = array_filter([ + 'etag' => $normalized['etag'] ?? null, + 'version' => $normalized['version'] ?? null, + 'versionid' => $normalized['versionid'] ?? null, + ], fn ($v) => $v !== null && $v !== ''); + + $size = $normalized['size'] ?? null; + + return new FileAttributes( + $normalized['path'], + is_numeric($size) ? (int) $size : null, + $normalized['visibility'] ?? 'public', + $lastModified, + $normalized['mimetype'] ?? null, + $extra + ); } private function getMetadata(string $path, string $type): FileAttributes { + $prefixed = $this->ensureFolderIsPrefixed(trim($path, '/')); + try { - $result = (array) $this->cloudinary->adminApi()->asset($path); + $result = (array) $this->cloudinary->adminApi()->asset($prefixed); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::create($path, $type, '', $exception); } @@ -498,8 +563,11 @@ private function getMetadata(string $path, string $type): FileAttributes private function mapToFileAttributes($resource): FileAttributes { + $publicId = $resource['public_id']; + $logicalPath = $this->ensurePrefixedFolderIsRemoved($publicId); + return new FileAttributes( - $resource['public_id'], + $logicalPath, (int) $resource['bytes'], 'public', (int) strtotime($resource['created_at']), @@ -659,7 +727,7 @@ protected function normalizeResponse( 'mimetype' => (new FinfoMimeTypeDetector)->detectMimeType($path, $body) ?? 'text/plain', 'path' => $path, 'size' => Arr::get($response, 'bytes'), - 'timestamp' => strtotime(Arr::get($response, 'created_at')), + 'timestamp' => strtotime((string) Arr::get($response, 'created_at')), 'type' => 'file', 'version' => Arr::get($response, 'version'), 'versionid' => Arr::get($response, 'version_id'), @@ -673,7 +741,7 @@ public function fileExists(string $path): bool try { $this->cloudinary->adminApi()->asset($path); - } catch (Exception $e) { + } catch (Exception) { return false; } @@ -682,36 +750,55 @@ public function fileExists(string $path): bool public function directoryExists(string $path): bool { - $folders = []; - $needle = substr($path, 0, strripos($path, '/')); - - $response = null; - do { - $response = (array) $this->cloudinary->adminApi()->subFolders($needle, [ - 'max_results' => 4, - 'next_cursor' => $response['next_cursor'] ?? null, - ]); - - $folders = array_merge($folders, $response['folders']); - } while (array_key_exists('next_cursor', $response) && ! is_null($response['next_cursor'])); - $folders_found = array_filter( - $folders, - function ($e) use ($path) { - return $e['path'] == $path; + $path = trim($path, '/'); + $prefixedPath = $this->ensureFolderIsPrefixed($path); + $pos = strrpos($prefixedPath, '/'); + $needle = $pos === false ? '' : substr($prefixedPath, 0, $pos); + + try { + $folders = []; + $response = null; + do { + $response = (array) $this->cloudinary->adminApi()->subFolders($needle, [ + 'max_results' => 500, + 'next_cursor' => $response['next_cursor'] ?? null, + ]); + + $folders = array_merge($folders, $response['folders'] ?? []); + } while (! empty($response['next_cursor'])); + + foreach ($folders as $folder) { + if (($folder['path'] ?? '') === $prefixedPath) { + return true; + } } - ); + } catch (Exception) { + return false; + } - return count($folders_found) > 0; + return false; } public function deleteDirectory(string $path): void { - $this->cloudinary->adminApi()->deleteFolder($path); + $prefixed = $this->ensureFolderIsPrefixed(trim($path, '/')); + + try { + $this->cloudinary->adminApi()->deleteFolder($prefixed); + } catch (Throwable $e) { + throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); + } } public function createDirectory(string $path, Config $config): void { - $this->cloudinary->adminApi()->createFolder($path); + $prefixed = $this->ensureFolderIsPrefixed(trim($path, '/')); + + try { + $this->cloudinary->adminApi()->createFolder($prefixed); + } catch (Throwable $e) { + throw UnableToCreateDirectory::atLocation($path, $e->getMessage(), $e); + } } public function setVisibility(string $path, string $visibility): void diff --git a/tests/Feature/AdapterTest.php b/tests/Feature/AdapterTest.php index e645eab..413701b 100644 --- a/tests/Feature/AdapterTest.php +++ b/tests/Feature/AdapterTest.php @@ -1,12 +1,14 @@ shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ 'secure_url' => '::url::', ], [])); - $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([], [])); + $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ + 'public_id' => '::to-path::', + 'version' => 1, + 'version_id' => 'v1', + 'created_at' => '2021-10-10T10:10:10Z', + 'bytes' => 3, + 'etag' => 'e', + 'access_mode' => 'public', + ], [])); }); $adapter = new FlysystemCloudinaryAdapter($mock); @@ -236,18 +246,17 @@ }); it('can read stream', function () { - Http::fake(); + Http::fake(['*' => Http::response('body')]); $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { $mock->shouldReceive('uploadApi->explicit')->once()->andReturn(new ApiResponse([ - 'secure_url' => '::url::', + 'secure_url' => 'https://example.test/file', ], [])); }); $adapter = new FlysystemCloudinaryAdapter($mock); - $meta = $adapter->readStream('::path::'); + $stream = $adapter->readStream('::path::'); - $this->assertIsResource($meta['stream']); - $this->assertArrayNotHasKey('contents', $meta); + $this->assertIsResource($stream); Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); }); @@ -263,12 +272,23 @@ }); $adapter = new FlysystemCloudinaryAdapter($mock); - $files = $adapter->listContents('::path::'); + $files = iterator_to_array($adapter->listContents('::path::', false)); $this->assertSame([], $files); Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 4); }); +it('does not copy if file is not found', function () { + Http::fake(); + $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { + $mock->shouldReceive('uploadApi->explicit')->times(3)->andThrow(new NotFound('not found')); + }); + $adapter = new FlysystemCloudinaryAdapter($mock); + + $this->expectException(UnableToCopyFile::class); + $adapter->copy('::missing::', '::to-path::', new Config); +}); + it('can get url via request', function () { Http::fake(); $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) { diff --git a/tests/Integration/CloudinaryTest.php b/tests/Integration/CloudinaryTest.php index 73fdc6c..872e63b 100644 --- a/tests/Integration/CloudinaryTest.php +++ b/tests/Integration/CloudinaryTest.php @@ -5,10 +5,16 @@ use Illuminate\Http\Testing\File; use Illuminate\Support\Facades\Event; use League\Flysystem\Config; +use League\Flysystem\UnableToCopyFile; use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; beforeEach(function () { + if (in_array(env('CLOUDINARY_CLOUD_NAME'), [null, '', 'cloudinary_cloud_name'], true)) { + $this->markTestSkipped('Set real CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET to run integration tests.'); + } + Event::fake(); $cloudinary = new Cloudinary([ @@ -67,9 +73,9 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void { $test->assertIsString($meta['contents']); $test->assertIsString($meta['etag']); - $test->assertSame('image/jpeg', $meta['mimetype']); + $test->assertContains($meta['mimetype'], ['image/jpeg', 'image/jpg']); $test->assertSame($publicId, $meta['path']); - $test->assertSame(695, $meta['size']); + $test->assertGreaterThan(0, $meta['size']); $test->assertIsInt($meta['timestamp']); $test->assertSame('file', $meta['type']); $test->assertIsInt($meta['version']); @@ -129,9 +135,8 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $path = 'file-does-not-exist'; $newPath = 'file-copied'; + $this->expectException(UnableToCopyFile::class); $this->adapter->copy($path, $newPath, new Config); - - $this->assertFalse($this->adapter->copied); }); it('can delete', function () { @@ -201,31 +206,28 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $fakeImage = File::image('black.jpg')->getContent(); $this->adapter->write($publicId, $fakeImage, new Config); - $meta = $this->adapter->readStream($publicId); + $stream = $this->adapter->readStream($publicId); - $this->assertIsResource($meta['stream']); - $this->assertArrayNotHasKey('contents', $meta); + $this->assertIsResource($stream); $this->adapter->delete($publicId); // cleanup }); it('does not read if file is not found', function () { $publicId = 'file-does-not-exist'; - $response = $this->adapter->read($publicId); - - $this->assertEmpty($response); + $this->expectException(UnableToReadFile::class); + $this->adapter->read($publicId); }); it('does not read stream if file is not found', function () { $publicId = 'file-does-not-exist'; - $bool = $this->adapter->readStream($publicId); - - $this->assertFalse($bool); + $this->expectException(UnableToReadFile::class); + $this->adapter->readStream($publicId); }); it('can list directory contents', function () { - $files = $this->adapter->listContents('sandbox'); + $files = iterator_to_array($this->adapter->listContents('sandbox', false)); $this->assertIsArray($files); }); @@ -237,7 +239,7 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $size = $this->adapter->getSize($publicId); - $this->assertEquals(695, $size); + $this->assertGreaterThan(0, $size); $this->adapter->delete($publicId); // cleanup }); @@ -255,7 +257,7 @@ function assertUploadResponse(mixed $test, array $meta, string $publicId): void $mimeType = $this->adapter->getMimetype($publicId); - $this->assertEquals('image/jpg', $mimeType); + $this->assertContains($mimeType, ['image/jpeg', 'image/jpg']); $this->adapter->delete($publicId); // cleanup }); diff --git a/tests/Pest.php b/tests/Pest.php index f02f3f5..8d00eaa 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,3 +3,5 @@ use CodebarAg\FlysystemCloudinary\Tests\TestCase; uses(TestCase::class)->in(__DIR__); + +uses()->group('integration')->in(__DIR__.'/Integration');