diff --git a/.github/CI.yaml b/.github/CI.yaml deleted file mode 100644 index 4509e24..0000000 --- a/.github/CI.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: PHP CI - -on: - pull_request: - branches: - - main - - develop - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.3" - - - name: Install dependencies - run: composer install - - - name: Run PHPMD - run: ./vendor/bin/phpmd src/ text phpmd.xml - - - name: Run PHP_CodeSniffer - run: ./vendor/bin/phpcs --standard=phpcs.xml - - - name: Run Pint Check - run: ./vendor/bin/pint --test - - - name: Run PHPStan - run: ./vendor/bin/phpstan analyse - - - name: Run Pest - run: ./vendor/bin/pest --coverage --min=100 --parallel diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index a8795a4..4509e24 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -35,4 +35,4 @@ jobs: run: ./vendor/bin/phpstan analyse - name: Run Pest - run: ./vendor/bin/pest --coverage --min=80 --parallel + run: ./vendor/bin/pest --coverage --min=100 --parallel diff --git a/.gitignore b/.gitignore index 6f6b32d..c1540a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor/ .env -.DS_Store \ No newline at end of file +.DS_Store +index.php diff --git a/README.md b/README.md index 27a4ffc..423958a 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,15 @@ composer require xray/azure-storage-php-sdk Setup Blob Storage ```php -use Xray\AzureStoragePhpSdk\BlobStorage\{BlobStorage, Config}; -use Xray\AzureStoragePhpSdk\Http\Request; - -$request = new Request(new Config([ - 'account' => 'your_account_name', - 'key' => 'your_account_key', -])); - -$blobStorage = new BlobStorage($request); +use Xray\AzureStoragePhpSdk\BlobStorage\BlobStorageClient; +use Xray\AzureStoragePhpSdk\Authentication\MicrosoftEntraId; + +$client = BlobStorageClient::create(new MicrosoftEntraId( + account: 'my_account', + directoryId: 'directory_id', + applicationId: 'application_id', + applicationSecret: 'application_secret', +)); ``` [Storage Account](docs/StorageAccount.md) @@ -40,4 +40,3 @@ This project is licensed under the [MIT License](LICENSE). - sjpereira2000@gmail.com - gabrielramos791@gmail.com -- erlonsodre@gmail.com diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..9d2aa8d --- /dev/null +++ b/captainhook.json @@ -0,0 +1,62 @@ +{ + "commit-msg": { + "enabled": false, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Beams", + "options": { + "subjectLength": 50, + "bodyLineLength": 72 + } + } + ] + }, + "pre-push": { + "enabled": true, + "actions": [ + { + "action": "./vendor/bin/phpmd src/ text phpmd.xml" + }, + { + "action": "./vendor/bin/phpcs --standard=phpcs.xml" + }, + { + "action": "./vendor/bin/pint --test" + }, + { + "action": "./vendor/bin/phpstan analyse" + }, + { + "action": "./vendor/bin/pest --coverage --min=100 --parallel" + } + ] + }, + "pre-commit": { + "enabled": false, + "actions": [] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + }, + "post-rewrite": { + "enabled": false, + "actions": [] + }, + "post-change": { + "enabled": false, + "actions": [] + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index bbb52d9..465c3fe 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "xray/azure-storage-php-sdk", - "description": "Integrate with Azure's cloud storage services", + "description": "Azure Storage PHP SDK", "type": "library", "license": "MIT", "scripts": { @@ -28,12 +28,14 @@ "pestphp/pest": "^2.34", "symfony/var-dumper": "^7.0", "phpmd/phpmd": "^2.15", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.10", + "captainhook/captainhook": "^5.23", + "captainhook/hook-installer": "^1.0", + "mockery/mockery": "^1.6" }, "authors": [ { "name": "Silvio Pereira", "email": "sjpereira2000@gmail.com" }, - { "name": "Gabriel de Ramos", "email": "gabrielramos791@gmail.com" }, - { "name": "Erlon Sodre", "email": "erlonsodre@gmail.com" } + { "name": "Gabriel de Ramos", "email": "gabrielramos791@gmail.com" } ], "autoload": { "psr-4": { @@ -47,7 +49,8 @@ "minimum-stability": "stable", "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "captainhook/hook-installer": true } } } diff --git a/composer.lock b/composer.lock index cb13b88..2656f77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,26 +4,26 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f245aa16953f9d0340a55ed5b81ef60e", + "content-hash": "882a9d7493b8a16afb001d6b42e6a3a2", "packages": [ { "name": "guzzlehttp/guzzle", - "version": "7.8.1", + "version": "7.9.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" + "reference": "d281ed313b989f213357e3be1a179f02196ac99b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.1", - "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -34,9 +34,9 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "guzzle/client-integration-tests": "3.0.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -114,7 +114,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.8.1" + "source": "https://github.com/guzzle/guzzle/tree/7.9.2" }, "funding": [ { @@ -130,20 +130,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:35:24+00:00" + "time": "2024-07-24T11:22:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", + "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", "shasum": "" }, "require": { @@ -151,7 +151,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "phpunit/phpunit": "^8.5.39 || ^9.6.20" }, "type": "library", "extra": { @@ -197,7 +197,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.2" + "source": "https://github.com/guzzle/promises/tree/2.0.3" }, "funding": [ { @@ -213,20 +213,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:19:20+00:00" + "time": "2024-07-18T10:29:17+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221" + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", "shasum": "" }, "require": { @@ -241,8 +241,8 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -313,7 +313,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.6.2" + "source": "https://github.com/guzzle/psr7/tree/2.7.0" }, "funding": [ { @@ -329,7 +329,7 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:05:35+00:00" + "time": "2024-07-18T11:15:46+00:00" }, { "name": "psr/http-client", @@ -698,32 +698,227 @@ ], "time": "2024-02-20T07:24:02+00:00" }, + { + "name": "captainhook/captainhook", + "version": "5.23.3", + "source": { + "type": "git", + "url": "https://github.com/captainhookphp/captainhook.git", + "reference": "c9deaefc098dde7f7093b44482b099195442e70d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhookphp/captainhook/zipball/c9deaefc098dde7f7093b44482b099195442e70d", + "reference": "c9deaefc098dde7f7093b44482b099195442e70d", + "shasum": "" + }, + "require": { + "captainhook/secrets": "^0.9.4", + "ext-json": "*", + "ext-spl": "*", + "ext-xml": "*", + "php": ">=8.0", + "sebastianfeldmann/camino": "^0.9.2", + "sebastianfeldmann/cli": "^3.3", + "sebastianfeldmann/git": "^3.10", + "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "sebastianfeldmann/captainhook": "*" + }, + "require-dev": { + "composer/composer": "~1 || ^2.0", + "mikey179/vfsstream": "~1" + }, + "bin": [ + "bin/captainhook" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0.x-dev" + }, + "captainhook": { + "config": "captainhook.json" + } + }, + "autoload": { + "psr-4": { + "CaptainHook\\App\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git hook manager", + "homepage": "http://php.captainhook.info/", + "keywords": [ + "commit-msg", + "git", + "hooks", + "post-merge", + "pre-commit", + "pre-push", + "prepare-commit-msg" + ], + "support": { + "issues": "https://github.com/captainhookphp/captainhook/issues", + "source": "https://github.com/captainhookphp/captainhook/tree/5.23.3" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2024-07-07T19:12:59+00:00" + }, + { + "name": "captainhook/hook-installer", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/captainhookphp/hook-installer.git", + "reference": "3308a9152727af4e3d1c7b63ca219d6938b702b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhookphp/hook-installer/zipball/3308a9152727af4e3d1c7b63ca219d6938b702b8", + "reference": "3308a9152727af4e3d1c7b63ca219d6938b702b8", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1|^2.0", + "php": ">=8.0" + }, + "require-dev": { + "composer/composer": "*" + }, + "type": "composer-plugin", + "extra": { + "class": "CaptainHook\\HookInstaller\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "CaptainHook\\HookInstaller\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Composer Plugin that makes everyone activate the CaptainHook git hooks locally", + "support": { + "issues": "https://github.com/captainhookphp/hook-installer/issues", + "source": "https://github.com/captainhookphp/hook-installer/tree/1.0.3" + }, + "time": "2024-03-21T13:39:59+00:00" + }, + { + "name": "captainhook/secrets", + "version": "0.9.5", + "source": { + "type": "git", + "url": "https://github.com/captainhookphp/secrets.git", + "reference": "8aa90d5b9b7892abd11b9da2fc172a7b32b90cbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhookphp/secrets/zipball/8aa90d5b9b7892abd11b9da2fc172a7b32b90cbe", + "reference": "8aa90d5b9b7892abd11b9da2fc172a7b32b90cbe", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CaptainHook\\Secrets\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Utility classes to detect secrets", + "keywords": [ + "commit-msg", + "keys", + "passwords", + "post-merge", + "prepare-commit-msg", + "secrets", + "tokens" + ], + "support": { + "issues": "https://github.com/captainhookphp/secrets/issues", + "source": "https://github.com/captainhookphp/secrets/tree/0.9.5" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2023-11-30T18:10:18+00:00" + }, { "name": "composer/pcre", - "version": "3.1.4", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "04229f163664973f68f38f6f73d917799168ef24" + "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/04229f163664973f68f38f6f73d917799168ef24", - "reference": "04229f163664973f68f38f6f73d917799168ef24", + "url": "https://api.github.com/repos/composer/pcre/zipball/ea4ab6f9580a4fd221e0418f2c357cdd39102a90", + "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, + "conflict": { + "phpstan/phpstan": "<1.11.8" + }, "require-dev": { - "phpstan/phpstan": "^1.3", + "phpstan/phpstan": "^1.11.8", "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^5" + "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { "branch-alias": { "dev-main": "3.x-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] } }, "autoload": { @@ -751,7 +946,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.1.4" + "source": "https://github.com/composer/pcre/tree/3.2.0" }, "funding": [ { @@ -767,7 +962,7 @@ "type": "tidelift" } ], - "time": "2024-05-27T13:40:54+00:00" + "time": "2024-07-25T09:36:02+00:00" }, { "name": "composer/xdebug-handler", @@ -1014,6 +1209,57 @@ ], "time": "2023-11-03T12:00:00+00:00" }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "shasum": "" + }, + "require": { + "php": "^5.3|^7.0|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + }, + "time": "2020-07-09T08:09:16+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.0.6", @@ -1075,16 +1321,16 @@ }, { "name": "laravel/pint", - "version": "v1.16.1", + "version": "v1.17.2", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "9266a47f1b9231b83e0cfd849009547329d871b1" + "reference": "e8a88130a25e3f9d4d5785e6a1afca98268ab110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/9266a47f1b9231b83e0cfd849009547329d871b1", - "reference": "9266a47f1b9231b83e0cfd849009547329d871b1", + "url": "https://api.github.com/repos/laravel/pint/zipball/e8a88130a25e3f9d4d5785e6a1afca98268ab110", + "reference": "e8a88130a25e3f9d4d5785e6a1afca98268ab110", "shasum": "" }, "require": { @@ -1095,13 +1341,13 @@ "php": "^8.1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.59.3", - "illuminate/view": "^10.48.12", - "larastan/larastan": "^2.9.7", + "friendsofphp/php-cs-fixer": "^3.61.1", + "illuminate/view": "^10.48.18", + "larastan/larastan": "^2.9.8", "laravel-zero/framework": "^10.4.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^1.15.1", - "pestphp/pest": "^2.34.8" + "pestphp/pest": "^2.35.0" }, "bin": [ "builds/pint" @@ -1137,7 +1383,90 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-06-18T16:50:05+00:00" + "time": "2024-08-06T15:11:54+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" }, { "name": "myclabs/deep-copy", @@ -1201,16 +1530,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.0.2", + "version": "v5.1.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13" + "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/139676794dc1e9231bf7bcd123cfc0c99182cb13", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", "shasum": "" }, "require": { @@ -1221,7 +1550,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -1253,44 +1582,44 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" }, - "time": "2024-03-05T20:51:40+00:00" + "time": "2024-07-01T20:03:41+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.1.1", + "version": "v8.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "13e5d538b95a744d85f447a321ce10adb28e9af9" + "reference": "e7d1aa8ed753f63fa816932bbc89678238843b4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/13e5d538b95a744d85f447a321ce10adb28e9af9", - "reference": "13e5d538b95a744d85f447a321ce10adb28e9af9", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/e7d1aa8ed753f63fa816932bbc89678238843b4a", + "reference": "e7d1aa8ed753f63fa816932bbc89678238843b4a", "shasum": "" }, "require": { "filp/whoops": "^2.15.4", "nunomaduro/termwind": "^2.0.1", "php": "^8.2.0", - "symfony/console": "^7.0.4" + "symfony/console": "^7.1.3" }, "conflict": { "laravel/framework": "<11.0.0 || >=12.0.0", "phpunit/phpunit": "<10.5.1 || >=12.0.0" }, "require-dev": { - "larastan/larastan": "^2.9.2", - "laravel/framework": "^11.0.0", - "laravel/pint": "^1.14.0", - "laravel/sail": "^1.28.2", - "laravel/sanctum": "^4.0.0", + "larastan/larastan": "^2.9.8", + "laravel/framework": "^11.19.0", + "laravel/pint": "^1.17.1", + "laravel/sail": "^1.31.0", + "laravel/sanctum": "^4.0.2", "laravel/tinker": "^2.9.0", - "orchestra/testbench-core": "^9.0.0", - "pestphp/pest": "^2.34.1 || ^3.0.0", - "sebastian/environment": "^6.0.1 || ^7.0.0" + "orchestra/testbench-core": "^9.2.3", + "pestphp/pest": "^2.35.0 || ^3.0.0", + "sebastian/environment": "^6.1.0 || ^7.0.0" }, "type": "library", "extra": { @@ -1352,7 +1681,7 @@ "type": "patreon" } ], - "time": "2024-03-06T16:20:09+00:00" + "time": "2024-08-03T15:32:23+00:00" }, { "name": "nunomaduro/termwind", @@ -1507,21 +1836,21 @@ }, { "name": "pestphp/pest", - "version": "v2.34.8", + "version": "v2.35.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "e8f122bf47585c06431e0056189ec6bfd6f41f57" + "reference": "d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/e8f122bf47585c06431e0056189ec6bfd6f41f57", - "reference": "e8f122bf47585c06431e0056189ec6bfd6f41f57", + "url": "https://api.github.com/repos/pestphp/pest/zipball/d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646", + "reference": "d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646", "shasum": "" }, "require": { "brianium/paratest": "^7.3.1", - "nunomaduro/collision": "^7.10.0|^8.1.1", + "nunomaduro/collision": "^7.10.0|^8.3.0", "nunomaduro/termwind": "^1.15.1|^2.0.1", "pestphp/pest-plugin": "^2.1.1", "pestphp/pest-plugin-arch": "^2.7.0", @@ -1535,8 +1864,8 @@ }, "require-dev": { "pestphp/pest-dev-tools": "^2.16.0", - "pestphp/pest-plugin-type-coverage": "^2.8.3", - "symfony/process": "^6.4.0|^7.1.1" + "pestphp/pest-plugin-type-coverage": "^2.8.5", + "symfony/process": "^6.4.0|^7.1.3" }, "bin": [ "bin/pest" @@ -1599,7 +1928,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v2.34.8" + "source": "https://github.com/pestphp/pest/tree/v2.35.0" }, "funding": [ { @@ -1611,7 +1940,7 @@ "type": "github" } ], - "time": "2024-06-10T22:02:16+00:00" + "time": "2024-08-02T10:57:29+00:00" }, { "name": "pestphp/pest-plugin", @@ -2179,16 +2508,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.11.5", + "version": "1.11.10", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "490f0ae1c92b082f154681d7849aee776a7c1443" + "reference": "640410b32995914bde3eed26fa89552f9c2c082f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/490f0ae1c92b082f154681d7849aee776a7c1443", - "reference": "490f0ae1c92b082f154681d7849aee776a7c1443", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/640410b32995914bde3eed26fa89552f9c2c082f", + "reference": "640410b32995914bde3eed26fa89552f9c2c082f", "shasum": "" }, "require": { @@ -2233,20 +2562,20 @@ "type": "github" } ], - "time": "2024-06-17T15:10:54+00:00" + "time": "2024-08-08T09:02:50+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.14", + "version": "10.1.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b" + "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", - "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", + "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", "shasum": "" }, "require": { @@ -2303,7 +2632,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.14" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.15" }, "funding": [ { @@ -2311,7 +2640,7 @@ "type": "github" } ], - "time": "2024-03-12T15:33:41+00:00" + "time": "2024-06-29T08:25:15+00:00" }, { "name": "phpunit/php-file-iterator", @@ -3676,18 +4005,194 @@ ], "time": "2023-02-07T11:34:05+00:00" }, + { + "name": "sebastianfeldmann/camino", + "version": "0.9.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/camino.git", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/camino/zipball/bf2e4c8b2a029e9eade43666132b61331e3e8184", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Path management the OO way", + "homepage": "https://github.com/sebastianfeldmann/camino", + "keywords": [ + "file system", + "path" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/camino/issues", + "source": "https://github.com/sebastianfeldmann/camino/tree/0.9.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2022-01-03T13:15:10+00:00" + }, + { + "name": "sebastianfeldmann/cli", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/cli.git", + "reference": "8a932e99e9455981fb32fa6c085492462fe8f8cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/cli/zipball/8a932e99e9455981fb32fa6c085492462fe8f8cf", + "reference": "8a932e99e9455981fb32fa6c085492462fe8f8cf", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "symfony/process": "^4.3 | ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP cli helper classes", + "homepage": "https://github.com/sebastianfeldmann/cli", + "keywords": [ + "cli" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/cli/issues", + "source": "https://github.com/sebastianfeldmann/cli/tree/3.4.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2021-12-20T14:59:49+00:00" + }, + { + "name": "sebastianfeldmann/git", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/git.git", + "reference": "5cb1ea94f65c7420419abe8f12c45cc7eb094790" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/git/zipball/5cb1ea94f65c7420419abe8f12c45cc7eb094790", + "reference": "5cb1ea94f65c7420419abe8f12c45cc7eb094790", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "php": ">=8.0", + "sebastianfeldmann/cli": "^3.0" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Git\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git wrapper", + "homepage": "https://github.com/sebastianfeldmann/git", + "keywords": [ + "git" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/git/issues", + "source": "https://github.com/sebastianfeldmann/git/tree/3.11.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2024-01-23T09:11:14+00:00" + }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.1", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "8f90f7a53ce271935282967f53d0894f8f1ff877" + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/8f90f7a53ce271935282967f53d0894f8f1ff877", - "reference": "8f90f7a53ce271935282967f53d0894f8f1ff877", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", "shasum": "" }, "require": { @@ -3754,7 +4259,7 @@ "type": "open_collective" } ], - "time": "2024-05-22T21:24:41+00:00" + "time": "2024-07-21T23:26:44+00:00" }, { "name": "symfony/config", @@ -3833,16 +4338,16 @@ }, { "name": "symfony/console", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9b008f2d7b21c74ef4d0c3de6077a642bc55ece3" + "reference": "cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9b008f2d7b21c74ef4d0c3de6077a642bc55ece3", - "reference": "9b008f2d7b21c74ef4d0c3de6077a642bc55ece3", + "url": "https://api.github.com/repos/symfony/console/zipball/cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9", + "reference": "cb1dcb30ebc7005c29864ee78adb47b5fb7c3cd9", "shasum": "" }, "require": { @@ -3906,7 +4411,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.1" + "source": "https://github.com/symfony/console/tree/v7.1.3" }, "funding": [ { @@ -3922,20 +4427,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-07-26T12:41:01+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "77c636dfd86c0b60c5d184b2fd2ddf8dd11c309c" + "reference": "8126f0be4ff984e4db0140e60917900a53facb49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/77c636dfd86c0b60c5d184b2fd2ddf8dd11c309c", - "reference": "77c636dfd86c0b60c5d184b2fd2ddf8dd11c309c", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8126f0be4ff984e4db0140e60917900a53facb49", + "reference": "8126f0be4ff984e4db0140e60917900a53facb49", "shasum": "" }, "require": { @@ -3986,7 +4491,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.1.1" + "source": "https://github.com/symfony/dependency-injection/tree/v7.1.3" }, "funding": [ { @@ -4002,20 +4507,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-07-26T07:35:39+00:00" }, { "name": "symfony/filesystem", - "version": "v7.1.1", + "version": "v7.1.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2" + "reference": "92a91985250c251de9b947a14bb2c9390b1a562c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/802e87002f919296c9f606457d9fa327a0b3d6b2", - "reference": "802e87002f919296c9f606457d9fa327a0b3d6b2", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/92a91985250c251de9b947a14bb2c9390b1a562c", + "reference": "92a91985250c251de9b947a14bb2c9390b1a562c", "shasum": "" }, "require": { @@ -4052,7 +4557,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.1.1" + "source": "https://github.com/symfony/filesystem/tree/v7.1.2" }, "funding": [ { @@ -4068,20 +4573,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-06-28T10:03:55+00:00" }, { "name": "symfony/finder", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fbb0ba67688b780efbc886c1a0a0948dcf7205d6" + "reference": "717c6329886f32dc65e27461f80f2a465412fdca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fbb0ba67688b780efbc886c1a0a0948dcf7205d6", - "reference": "fbb0ba67688b780efbc886c1a0a0948dcf7205d6", + "url": "https://api.github.com/repos/symfony/finder/zipball/717c6329886f32dc65e27461f80f2a465412fdca", + "reference": "717c6329886f32dc65e27461f80f2a465412fdca", "shasum": "" }, "require": { @@ -4116,7 +4621,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.1.1" + "source": "https://github.com/symfony/finder/tree/v7.1.3" }, "funding": [ { @@ -4132,7 +4637,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-07-24T07:08:44+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4454,16 +4959,16 @@ }, { "name": "symfony/process", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "febf90124323a093c7ee06fdb30e765ca3c20028" + "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/febf90124323a093c7ee06fdb30e765ca3c20028", - "reference": "febf90124323a093c7ee06fdb30e765ca3c20028", + "url": "https://api.github.com/repos/symfony/process/zipball/7f2f542c668ad6c313dc4a5e9c3321f733197eca", + "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca", "shasum": "" }, "require": { @@ -4495,7 +5000,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.1" + "source": "https://github.com/symfony/process/tree/v7.1.3" }, "funding": [ { @@ -4511,7 +5016,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-07-26T12:44:47+00:00" }, { "name": "symfony/service-contracts", @@ -4598,16 +5103,16 @@ }, { "name": "symfony/string", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "60bc311c74e0af215101235aa6f471bcbc032df2" + "reference": "ea272a882be7f20cad58d5d78c215001617b7f07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/60bc311c74e0af215101235aa6f471bcbc032df2", - "reference": "60bc311c74e0af215101235aa6f471bcbc032df2", + "url": "https://api.github.com/repos/symfony/string/zipball/ea272a882be7f20cad58d5d78c215001617b7f07", + "reference": "ea272a882be7f20cad58d5d78c215001617b7f07", "shasum": "" }, "require": { @@ -4665,7 +5170,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.1" + "source": "https://github.com/symfony/string/tree/v7.1.3" }, "funding": [ { @@ -4681,20 +5186,20 @@ "type": "tidelift" } ], - "time": "2024-06-04T06:40:14+00:00" + "time": "2024-07-22T10:25:37+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.1.1", + "version": "v7.1.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "deb2c2b506ff6fdbb340e00b34e9901e1605f293" + "reference": "86af4617cca75a6e28598f49ae0690f3b9d4591f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/deb2c2b506ff6fdbb340e00b34e9901e1605f293", - "reference": "deb2c2b506ff6fdbb340e00b34e9901e1605f293", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86af4617cca75a6e28598f49ae0690f3b9d4591f", + "reference": "86af4617cca75a6e28598f49ae0690f3b9d4591f", "shasum": "" }, "require": { @@ -4748,7 +5253,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.1.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.1.3" }, "funding": [ { @@ -4764,20 +5269,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-07-26T12:41:01+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.1.1", + "version": "v7.1.2", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "db82c2b73b88734557cfc30e3270d83fa651b712" + "reference": "b80a669a2264609f07f1667f891dbfca25eba44c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/db82c2b73b88734557cfc30e3270d83fa651b712", - "reference": "db82c2b73b88734557cfc30e3270d83fa651b712", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b80a669a2264609f07f1667f891dbfca25eba44c", + "reference": "b80a669a2264609f07f1667f891dbfca25eba44c", "shasum": "" }, "require": { @@ -4824,7 +5329,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.1.1" + "source": "https://github.com/symfony/var-exporter/tree/v7.1.2" }, "funding": [ { @@ -4840,7 +5345,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:57:53+00:00" + "time": "2024-06-28T08:00:31+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/docs/StorageAccount.md b/docs/StorageAccount.md index b9b0f3a..35244b3 100644 --- a/docs/StorageAccount.md +++ b/docs/StorageAccount.md @@ -3,16 +3,16 @@ Getting the storage account information ```php -use Xray\AzureStoragePhpSdk\BlobStorage\{BlobStorage, Config}; -use Xray\AzureStoragePhpSdk\Http\Request; +use Xray\AzureStoragePhpSdk\BlobStorage\BlobStorageClient; +use Xray\AzureStoragePhpSdk\Authentication\MicrosoftEntraId; -$request = new Request(new Config([ - 'account' => 'your_account_name', - 'key' => 'your_account_key', -])); +$client = BlobStorageClient::create(new MicrosoftEntraId( + account: 'my_account', + directoryId: 'directory_id', + applicationId: 'application_id', + applicationSecret: 'application_secret', +)); -$blobStorage = new BlobStorage($request); - -$blobStorage->account()->information(); -// returns Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account\AccountInformation; +$client->account()->information(); +// ?^ returns Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account\AccountInformation; ``` diff --git a/src/Application/Application.php b/src/Application/Application.php new file mode 100644 index 0000000..79e4647 --- /dev/null +++ b/src/Application/Application.php @@ -0,0 +1,324 @@ + $instances + */ + protected array $instances = []; + + /** + * All bindings registered in the container. + * + * @var array $bindings + */ + protected array $bindings = []; + + /** + * All scope instances registered in the container. + * + * @var array $scopeInstances + */ + protected array $scopeInstances = []; + + /** + * All scope methods registered in the container. + * + * @var array $scopeMethods + */ + protected array $scopeMethods = []; + + /** + * Create a new application instance. + */ + public function __construct() + { + $this->instance(self::class, $this); + } + + /** + * Get the application singleton instance. + * + * @return self + */ + public static function getInstance(): self + { + if (!isset(self::$instance)) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Bind an instance to the container. + * + * @template TInstance of object + * + * @param string $key + * @param TInstance $instance + * @return self + */ + public function instance(string $key, object $instance): self + { + $this->instances[$key] = $instance; + + return $this; + } + + /** + * Bind a singleton callback to the container. + * + * @param string|class-string $key + * @param callable|null $callback + * @return self + */ + public function singleton(string $key, ?callable $callback = null): self + { + unset($this->instances[$key]); + $this->bind($key, $callback, shared: true); + + return $this; + } + + /** + * Bind a callback to the container. + * + * @param string|class-string $key + * @param callable|null $callback + * @param bool $shared + * @return self + */ + public function bind(string $key, ?callable $callback = null, bool $shared = false): self + { + if (is_null($callback) && !class_exists($key)) { + throw InvalidArgumentException::create("Cannot bind {$key} without a callback"); + } + + if (is_null($callback)) { + /** @var class-string $key */ + $callback = fn () => $this->build($key); + } + + $this->bindings[$key] = [ + 'callback' => $callback, + 'shared' => $shared, + ]; + + return $this; + } + + /** + * Bind a scoped callback to the container. + * + * @param string|class-string $key + * @param callable|null $callback + * @return self + */ + public function scope(string $key, ?callable $callback = null): self + { + if (is_null($callback) && !class_exists($key)) { + throw InvalidArgumentException::create("Cannot scope {$key} without a callback"); + } + + if (is_null($callback)) { + /** @var class-string $key */ + $callback = fn () => $this->build($key); + } + + unset($this->scopeInstances[$key]); + $this->scopeMethods[$key] = $callback; + + return $this; + } + + /** + * Determine if the container has a given instance or binding. + * + * @param string $key + * @return boolean + */ + public function bound(string $key): bool + { + return isset($this->instances[$key]) + || isset($this->bindings[$key]) + || isset($this->scopeInstances[$key]) + || isset($this->scopeMethods[$key]); + } + + /** + * Resolve an instance from the container. + * + * @template TClass of object + * + * @param string|class-string $key + * @param array $parameters + * @return ($key is class-string ? TClass : mixed) + */ + public function make(string $key, array $parameters = []): mixed + { + if (isset($this->instances[$key]) || isset($this->scopeInstances[$key])) { + return $this->instances[$key] ?? $this->scopeInstances[$key]; + } + + if (isset($this->bindings[$key]) || isset($this->scopeMethods[$key])) { + return $this->resolveBinding($key); + } + + if (!class_exists($key)) { + throw InvalidArgumentException::create("Cannot resolve class {$key}"); + } + + /** @var class-string $key */ + return $this->build($key, $parameters); + } + + /** + * Resolve a callback with the container. + * + * @param callable $callback + * @param array $parameters + * @return mixed + */ + public function call(callable $callback, array $parameters = []): mixed + { + $reflection = new ReflectionFunction(Closure::fromCallable($callback)); + $dependencies = $this->resolveDependencies($reflection->getParameters(), $parameters); + + return call_user_func_array($reflection->getClosure(), $dependencies); + } + + /** + * Flush the container of all bindings and resolved instances. + * + * @return self + */ + public function flush(): self + { + $this->instances = []; + $this->bindings = []; + $this->scopeInstances = []; + $this->scopeMethods = []; + + return $this; + } + + /** + * Flush the container of all scoped bindings and scoped instances. + * + * @return self + */ + public function flushScoped(): self + { + $this->scopeInstances = []; + $this->scopeMethods = []; + + return $this; + } + + /** + * Resolve an instance binding from the container. + * + * @param string $key + * @return mixed + */ + protected function resolveBinding(string $key): mixed + { + /** + * @var callable $callback + * @var bool $shared + * @var bool $scoped + */ + [$callback, $shared, $scoped] = isset($this->bindings[$key]) + ? [...array_values($this->bindings[$key]), false] + : [$this->scopeMethods[$key], false, true]; + + /** @var object $concrete */ + $concrete = $this->call($callback); + + if ($shared && !$scoped) { + $this->instances[$key] = $concrete; + unset($this->bindings[$key]); + } + + if ($scoped) { + $this->scopeInstances[$key] = $concrete; + unset($this->scopeMethods[$key]); + } + + return $concrete; + } + + /** + * Build an instance from the container. + * + * @template TClass of object + * + * @param class-string $key + * @param array $parameters + * @return TClass + */ + protected function build(string $key, array $parameters = []): object + { + $reflection = new ReflectionClass($key); + $constructor = $reflection->getConstructor(); + + if (is_null($constructor)) { + return $reflection->newInstance(); + } + + $dependencies = $this->resolveDependencies($constructor->getParameters(), $parameters); + + return $reflection->newInstanceArgs($dependencies); + } + + /** + * Build an array of dependencies from the container. + * + * @param \ReflectionParameter[] $dependencies + * @param array $parameters + * @return array + */ + protected function resolveDependencies(array $dependencies, array $parameters): array + { + return array_map(function (ReflectionParameter $dependency) use ($parameters): mixed { + $name = $dependency->getName(); + + if (array_key_exists($name, $parameters)) { + return $parameters[$name]; + } + + if ($dependency->isDefaultValueAvailable()) { + return $dependency->getDefaultValue(); + } + + $type = $dependency->getType(); + + if (is_null($type) || !$type instanceof ReflectionNamedType || $type->isBuiltin()) { + throw InvalidArgumentException::create("Cannot resolve parameter \${$name} without one defined type"); + } + + return $this->make($type->getName(), $parameters); + }, $dependencies); + } +} diff --git a/src/Authentication/MicrosoftEntraId.php b/src/Authentication/MicrosoftEntraId.php index 9e0ac7e..c2bdd6a 100644 --- a/src/Authentication/MicrosoftEntraId.php +++ b/src/Authentication/MicrosoftEntraId.php @@ -5,15 +5,20 @@ namespace Xray\AzureStoragePhpSdk\Authentication; use DateTime; -use GuzzleHttp\Client; +use GuzzleHttp\{Client, ClientInterface}; use Psr\Http\Client\RequestExceptionInterface; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; +use Xray\AzureStoragePhpSdk\Concerns\UseCurrentHttpDate; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; +use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Exceptions\RequestException; -use Xray\AzureStoragePhpSdk\Http\Headers; final class MicrosoftEntraId implements Auth { + use UseCurrentHttpDate; + + protected ?ClientInterface $client = null; + protected string $token = ''; protected ?DateTime $tokenExpiresAt = null; @@ -27,9 +32,11 @@ public function __construct( // } - public function getDate(): string + public function withRequestClient(ClientInterface $client): self { - return gmdate('D, d M Y H:i:s T'); + $this->client = $client; + + return $this; } public function getAccount(): string @@ -37,12 +44,9 @@ public function getAccount(): string return $this->account; } - public function getAuthentication( - HttpVerb $verb, - Headers $headers, - string $resource, - ): string { - if (!empty($this->token) && $this->tokenExpiresAt > new DateTime()) { + public function getAuthentication(Request $request): string + { + if (!empty($this->token) && $this->tokenExpiresAt && $this->tokenExpiresAt > new DateTime()) { return $this->token; } @@ -54,7 +58,10 @@ public function getAuthentication( protected function authenticate(): void { try { - $response = (new Client())->post("https://login.microsoftonline.com/{$this->directoryId}/oauth2/v2.0/token", [ + $uri = "https://login.microsoftonline.com/{$this->directoryId}/oauth2/v2.0/token"; + $httpVerb = HttpVerb::POST; + + $response = $this->getRequestClient()->request($httpVerb->value, $uri, [ 'form_params' => [ 'grant_type' => 'client_credentials', 'client_id' => $this->applicationId, @@ -62,15 +69,26 @@ protected function authenticate(): void 'scope' => 'https://storage.azure.com/.default', ], ]); + + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd /** @var array{token_type: string, expires_in: int, access_token: string} $body */ $body = json_decode((string) $response->getBody(), true); - $this->token = "{$body['token_type']} {$body['access_token']}"; - + $this->token = "{$body['token_type']} {$body['access_token']}"; $this->tokenExpiresAt = (new DateTime())->modify("+{$body['expires_in']} seconds"); } + + protected function getRequestClient(): ClientInterface + { + if (!isset($this->client)) { + $this->client = azure_app(Client::class); // @codeCoverageIgnore + } + + return $this->client; + } } diff --git a/src/Authentication/SharedAccessSignature/UserDelegationSas.php b/src/Authentication/SharedAccessSignature/UserDelegationSas.php new file mode 100644 index 0000000..c010982 --- /dev/null +++ b/src/Authentication/SharedAccessSignature/UserDelegationSas.php @@ -0,0 +1,106 @@ + */ + protected const RESOURCE_MAP = [ + 'b' => 'blob', + 'c' => 'container', + 'f' => 'file', + 's' => 'share', + 'd' => 'directory', + ]; + + public function __construct(protected Request $request) + { + if (!$this->request->getAuth() instanceof MicrosoftEntraId) { + throw InvalidAuthenticationMethodException::create(sprintf( + 'Invalid Authentication Method. [%s] needed, but [%s] given.', + MicrosoftEntraId::class, + get_class($this->request->getAuth()), + )); + } + } + + public function buildTokenUrl(AccessTokenPermission $permission, DateTimeImmutable $expiry): string + { + $account = $this->request->getAuth()->getAccount(); + $resource = ltrim($this->request->getResource(), '/'); + + $userDelegationKey = $this->getUserDelegationKey($expiry); + + if (!in_array($userDelegationKey->signedService, array_keys(static::RESOURCE_MAP))) { + throw InvalidResourceTypeException::create(sprintf( + 'The [%s] signed service is not valid. The allowed services are [%s].', + $userDelegationKey->signedService, + implode(', ', array_keys(static::RESOURCE_MAP)), + )); + } + + $service = static::RESOURCE_MAP[$userDelegationKey->signedService]; + $signedResource = "/{$service}/{$account}/{$resource}"; + $signedProtocol = parse_url($this->request->uri(), PHP_URL_SCHEME) ?? 'https'; + + $parameters = [ + SignatureResource::SIGNED_PERMISSION => $permission->value, + SignatureResource::SIGNED_START => convert_to_ISO($userDelegationKey->signedStart), + SignatureResource::SIGNED_EXPIRY => convert_to_ISO($userDelegationKey->signedExpiry), + SignatureResource::SIGNED_CANONICAL_RESOURCE => $signedResource, + SignatureResource::SIGNED_OBJECT_ID => $userDelegationKey->signedOid, + SignatureResource::SIGNED_TENANT_ID => $userDelegationKey->signedTid, + SignatureResource::SIGNED_KEY_START_TIME => convert_to_ISO($userDelegationKey->signedStart), + SignatureResource::SIGNED_KEY_EXPIRY_TIME => convert_to_ISO($userDelegationKey->signedExpiry), + SignatureResource::SIGNED_KEY_SERVICE => $userDelegationKey->signedService, + SignatureResource::SIGNED_KEY_VERSION => $userDelegationKey->signedVersion, + SignatureResource::SIGNED_AUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_UNAUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_CORRELATION_ID => null, + SignatureResource::SIGNED_IP_ADDRESS => null, + SignatureResource::SIGNED_PROTOCOL => $signedProtocol, + SignatureResource::SIGNED_VERSION => $userDelegationKey->signedVersion, + SignatureResource::SIGNED_RESOURCE => $userDelegationKey->signedService, + SignatureResource::SIGNED_SNAPSHOT_TIME => null, + SignatureResource::SIGNED_ENCRYPTION_SCOPE => null, + SignatureResource::RESOURCE_CACHE_CONTROL => null, + SignatureResource::RESOURCE_CONTENT_DISPOSITION => null, + SignatureResource::RESOURCE_CONTENT_ENCODING => null, + SignatureResource::RESOURCE_CONTENT_LANGUAGE => null, + SignatureResource::RESOURCE_CONTENT_TYPE => null, + ]; + + $stringToSign = implode("\n", $parameters); + $signature = base64_encode(hash_hmac('sha256', $stringToSign, base64_decode($userDelegationKey->value), true)); + + unset($parameters[SignatureResource::SIGNED_CANONICAL_RESOURCE]); + + $queryParams = array_filter($parameters); + $queryParams[SignatureResource::SIGNATURE] = $signature; + + return http_build_query($queryParams); + } + + protected function getUserDelegationKey(DateTimeImmutable $expiry): UserDelegationKey + { + $keyInfo = azure_app(KeyInfo::class, ['keyInfo' => [ + 'Start' => new DateTimeImmutable(), + 'Expiry' => $expiry, + ]]); + + return azure_app(AccountManager::class)->userDelegationKey($keyInfo); + } +} diff --git a/src/Authentication/SharedKeyAuth.php b/src/Authentication/SharedKeyAuth.php index a0254a7..8f6a488 100644 --- a/src/Authentication/SharedKeyAuth.php +++ b/src/Authentication/SharedKeyAuth.php @@ -4,39 +4,33 @@ namespace Xray\AzureStoragePhpSdk\Authentication; -use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; +use Xray\AzureStoragePhpSdk\Concerns\UseCurrentHttpDate; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; -use Xray\AzureStoragePhpSdk\Http\Headers; +use Xray\AzureStoragePhpSdk\Contracts\Http\Request; final class SharedKeyAuth implements Auth { + use UseCurrentHttpDate; + public function __construct(protected string $account, protected string $key) { // } - public function getDate(): string - { - return gmdate('D, d M Y H:i:s T'); - } - public function getAccount(): string { return $this->account; } - public function getAuthentication( - HttpVerb $verb, - Headers $headers, - string $resource, - ): string { + public function getAuthentication(Request $request): string + { $key = base64_decode($this->key); $stringToSign = $this->getSigningString( - $verb->value, - $headers->toString(), - $headers->getCanonicalHeaders(), - $resource, + $request->getVerb()->value, + $request->getHttpHeaders()->toString(), + $request->getHttpHeaders()->getCanonicalHeaders(), + $request->getResource(), ); $signature = base64_encode(hash_hmac('sha256', $stringToSign, $key, true)); diff --git a/src/BlobStorage/BlobStorage.php b/src/BlobStorage/BlobStorage.php deleted file mode 100644 index 53fe271..0000000 --- a/src/BlobStorage/BlobStorage.php +++ /dev/null @@ -1,32 +0,0 @@ -request); - } - - public function containers(): ContainerManager - { - return new ContainerManager($this->request); - } - - public function blobs(string $containerName): BlobManager - { - return new BlobManager($this->request, $containerName); - } -} diff --git a/src/BlobStorage/BlobStorageClient.php b/src/BlobStorage/BlobStorageClient.php new file mode 100644 index 0000000..432479e --- /dev/null +++ b/src/BlobStorage/BlobStorageClient.php @@ -0,0 +1,42 @@ +instance(RequestContract::class, $this->request); + azure_app()->instance(Config::class, $this->request->getConfig()); + } + + /** @param array{version?: string, parser?: Parser, converter?: Converter} $config */ + public static function create(Auth $auth, array $config = []): static + { + return new static(new Request($auth, new Config($config))); + } + + public function account(): AccountManager + { + return azure_app(AccountManager::class); + } + + public function containers(): ContainerManager + { + return azure_app(ContainerManager::class); + } + + public function blobs(string $containerName): BlobManager + { + return azure_app(BlobManager::class, ['containerName' => $containerName]); + } +} diff --git a/src/BlobStorage/Config.php b/src/BlobStorage/Config.php index 8f41a36..c4e7423 100644 --- a/src/BlobStorage/Config.php +++ b/src/BlobStorage/Config.php @@ -4,7 +4,6 @@ namespace Xray\AzureStoragePhpSdk\BlobStorage; -use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Xray\AzureStoragePhpSdk\Contracts\{Converter, Parser}; use Xray\AzureStoragePhpSdk\Converter\XmlConverter; use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; @@ -25,10 +24,10 @@ * @param ConfigType $config * @throws InvalidArgumentException */ - public function __construct(public Auth $auth, array $config = []) + public function __construct(array $config = []) { $this->version = $config['version'] ?? Resource::VERSION; - $this->parser = $config['parser'] ?? new XmlParser(); - $this->converter = $config['converter'] ?? new XmlConverter(); + $this->parser = $config['parser'] ?? azure_app(XmlParser::class); + $this->converter = $config['converter'] ?? azure_app(XmlConverter::class); } } diff --git a/src/BlobStorage/Entities/Account/BlobStorageProperty/BlobProperty.php b/src/BlobStorage/Entities/Account/BlobStorageProperty/BlobProperty.php index 4dec217..8412fa8 100644 --- a/src/BlobStorage/Entities/Account/BlobStorageProperty/BlobProperty.php +++ b/src/BlobStorage/Entities/Account/BlobStorageProperty/BlobProperty.php @@ -42,31 +42,29 @@ public function __construct(array $blobProperty) $this->defaultServiceVersion = $blobProperty['DefaultServiceVersion'] ?? ''; $this->logging = isset($blobProperty['Logging']) - ? new Logging($blobProperty['Logging']) + ? azure_app(Logging::class, ['logging' => $blobProperty['Logging']]) : null; // @codeCoverageIgnore $this->hourMetrics = isset($blobProperty['HourMetrics']) - ? new HourMetrics($blobProperty['HourMetrics']) + ? azure_app(HourMetrics::class, ['hourMetrics' => $blobProperty['HourMetrics']]) : null; // @codeCoverageIgnore $this->minuteMetrics = isset($blobProperty['MinuteMetrics']) - ? new MinuteMetrics($blobProperty['MinuteMetrics']) + ? azure_app(MinuteMetrics::class, ['minuteMetrics' => $blobProperty['MinuteMetrics']]) : null; // @codeCoverageIgnore if (isset($blobProperty['Cors'])) { - $this->cors = isset($blobProperty['Cors']['CorsRule']) - ? new Cors($blobProperty['Cors']['CorsRule']) - : new Cors([]); // @codeCoverageIgnore + $this->cors = azure_app(Cors::class, isset($blobProperty['Cors']['CorsRule']) ? ['corsRules' => $blobProperty['Cors']['CorsRule']] : []); } else { $this->cors = null; // @codeCoverageIgnore } $this->deleteRetentionPolicy = isset($blobProperty['DeleteRetentionPolicy']) - ? new DeleteRetentionPolicy($blobProperty['DeleteRetentionPolicy']) + ? azure_app(DeleteRetentionPolicy::class, ['deleteRetentionPolicy' => $blobProperty['DeleteRetentionPolicy']]) : null; // @codeCoverageIgnore $this->staticWebsite = isset($blobProperty['StaticWebsite']) - ? new StaticWebsite($blobProperty['StaticWebsite']) + ? azure_app(StaticWebsite::class, ['staticWebsite' => $blobProperty['StaticWebsite']]) : null; // @codeCoverageIgnore } @@ -106,6 +104,6 @@ public function toArray(): array public function toXml(): string { - return (new XmlConverter())->convert($this->toArray()); + return azure_app(XmlConverter::class)->convert($this->toArray()); } } diff --git a/src/BlobStorage/Entities/Account/BlobStorageProperty/Cors/Cors.php b/src/BlobStorage/Entities/Account/BlobStorageProperty/Cors/Cors.php index 58c4422..edc1052 100644 --- a/src/BlobStorage/Entities/Account/BlobStorageProperty/Cors/Cors.php +++ b/src/BlobStorage/Entities/Account/BlobStorageProperty/Cors/Cors.php @@ -17,7 +17,7 @@ final class Cors extends Collection implements Arrayable { /** @param CorsRuleType|CorsRuleType[] $corsRules */ - public function __construct(array $corsRules) + public function __construct(array $corsRules = []) { $firstKey = array_keys($corsRules)[0] ?? null; @@ -46,7 +46,7 @@ public function toArray(): array protected function generateCorsList(array $corsRules): array { return array_map( - fn (array $rule): CorsRule => new CorsRule($rule), + fn (array $rule): CorsRule => azure_app(CorsRule::class, ['corsRule' => $rule]), $corsRules, ); } diff --git a/src/BlobStorage/Entities/Account/KeyInfo.php b/src/BlobStorage/Entities/Account/KeyInfo.php index a2a3595..003cf09 100644 --- a/src/BlobStorage/Entities/Account/KeyInfo.php +++ b/src/BlobStorage/Entities/Account/KeyInfo.php @@ -5,6 +5,7 @@ namespace Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account; use DateTimeImmutable; +use DateTimeInterface; use Xray\AzureStoragePhpSdk\Contracts\{Arrayable, Xmlable}; use Xray\AzureStoragePhpSdk\Converter\XmlConverter; use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; @@ -12,12 +13,12 @@ /** @implements Arrayable */ final readonly class KeyInfo implements Arrayable, Xmlable { - public DateTimeImmutable $start; + public DateTimeInterface $start; - public DateTimeImmutable $expiry; + public DateTimeInterface $expiry; /** - * @param array{Start?: string, Expiry?: string} $keyInfo + * @param array{Start?: string|DateTimeInterface, Expiry?: string|DateTimeInterface} $keyInfo * * @throws RequiredFieldException */ @@ -31,22 +32,27 @@ public function __construct(array $keyInfo) } // @codeCoverageIgnoreEnd - $this->start = new DateTimeImmutable($keyInfo['Start']); - $this->expiry = new DateTimeImmutable($keyInfo['Expiry']); + $this->start = $keyInfo['Start'] instanceof DateTimeInterface + ? $keyInfo['Start'] + : new DateTimeImmutable($keyInfo['Start']); + + $this->expiry = $keyInfo['Expiry'] instanceof DateTimeInterface + ? $keyInfo['Expiry'] + : new DateTimeImmutable($keyInfo['Expiry']); } public function toArray(): array { return [ 'KeyInfo' => [ - 'Start' => $this->start->format(DateTimeImmutable::ATOM), - 'Expiry' => $this->expiry->format(DateTimeImmutable::ATOM), + 'Start' => convert_to_ISO($this->start), // @phpstan-ignore-line + 'Expiry' => convert_to_ISO($this->expiry), // @phpstan-ignore-line ], ]; } public function toXml(): string { - return (new XmlConverter())->convert($this->toArray()); + return azure_app(XmlConverter::class)->convert($this->toArray()); } } diff --git a/src/BlobStorage/Entities/Blob/Blob.php b/src/BlobStorage/Entities/Blob/Blob.php index 1f871bd..6a978f3 100644 --- a/src/BlobStorage/Entities/Blob/Blob.php +++ b/src/BlobStorage/Entities/Blob/Blob.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\ExpirationOption; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\{BlobLeaseManager, BlobManager, BlobTagManager}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resources\File; use Xray\AzureStoragePhpSdk\Concerns\HasManager; use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; @@ -27,9 +28,7 @@ final class Blob public readonly ?string $snapshotOriginalRaw; - public readonly DateTimeImmutable $versionId; - - public readonly ?string $versionIdOriginalRaw; + public readonly string $versionId; public readonly bool $isCurrentVersion; @@ -47,16 +46,14 @@ public function __construct(array $blob) throw RequiredFieldException::missingField('Name'); // @codeCoverageIgnore } - $this->name = $name; - $this->snapshot = isset($blob['Snapshot']) ? new DateTimeImmutable($blob['Snapshot']) : null; - $this->snapshotOriginalRaw = $blob['Snapshot'] ?? null; - $this->versionId = new DateTimeImmutable($blob['Version'] ?? 'now'); - $this->versionIdOriginalRaw = $blob['Version'] ?? null; - $this->isCurrentVersion = to_boolean($blob['IsCurrentVersion'] ?? true); - - $this->properties = new Properties($blob['Properties'] ?? []); + $this->name = $name; + $this->snapshot = isset($blob['Snapshot']) ? new DateTimeImmutable($blob['Snapshot']) : null; + $this->snapshotOriginalRaw = $blob['Snapshot'] ?? null; + $this->versionId = $blob['Version'] ?? ''; + $this->isCurrentVersion = to_boolean($blob['IsCurrentVersion'] ?? true); - $this->deleted = to_boolean($blob['Deleted'] ?? false); + $this->properties = azure_app(Properties::class, ['property' => $blob['Properties'] ?? []]); + $this->deleted = to_boolean($blob['Deleted'] ?? false); } /** @param array $options */ diff --git a/src/BlobStorage/Entities/Blob/BlobLease.php b/src/BlobStorage/Entities/Blob/BlobLease.php index 72a199d..4aa84ad 100644 --- a/src/BlobStorage/Entities/Blob/BlobLease.php +++ b/src/BlobStorage/Entities/Blob/BlobLease.php @@ -42,8 +42,7 @@ public function __construct(array $blobLease) $this->version = $blobLease[Resource::AUTH_VERSION] ?? ''; $this->date = new DateTimeImmutable($blobLease['Date'] ?? 'now'); - $this->leaseId = $blobLease[Resource::LEASE_ID] - ?? null; + $this->leaseId = $blobLease[Resource::LEASE_ID] ?? null; } public function renew(): self diff --git a/src/BlobStorage/Entities/Blob/BlobMetadata.php b/src/BlobStorage/Entities/Blob/BlobMetadata.php index 91345ff..3759d97 100644 --- a/src/BlobStorage/Entities/Blob/BlobMetadata.php +++ b/src/BlobStorage/Entities/Blob/BlobMetadata.php @@ -42,8 +42,8 @@ public function __construct(public array $metadata, array $options = []) $this->eTag = $options['ETag'] ?? null; $this->vary = $options['Vary'] ?? null; $this->server = $options['Server'] ?? null; - $this->xMsRequestId = $options['x-ms-request-id'] ?? null; - $this->xMsVersion = $options['x-ms-version'] ?? null; + $this->xMsRequestId = $options[Resource::REQUEST_ID] ?? null; + $this->xMsVersion = $options[Resource::AUTH_VERSION] ?? null; $this->date = isset($options['Date']) ? new DateTimeImmutable($options['Date']) : null; } diff --git a/src/BlobStorage/Entities/Blob/BlobProperty.php b/src/BlobStorage/Entities/Blob/BlobProperty.php index cef0883..907da8a 100644 --- a/src/BlobStorage/Entities/Blob/BlobProperty.php +++ b/src/BlobStorage/Entities/Blob/BlobProperty.php @@ -178,7 +178,7 @@ public function __construct(array $property) $this->resourceType = $property['x-ms-resource-type'] ?? null; $this->expiryTime = isset($property['x-ms-expiry-time']) ? new DateTimeImmutable($property['x-ms-expiry-time']) : null; $this->acl = isset($property['x-ms-acl']) ? array_pad(explode(':', $property['x-ms-acl']), 4, '') : null; - $this->metadata = new BlobMetadata(array_filter((array) $property, fn (string $key) => str_starts_with($key, Resource::METADATA_PREFIX), ARRAY_FILTER_USE_KEY)); + $this->metadata = azure_app(BlobMetadata::class, ['metadata' => array_filter((array) $property, fn (string $key) => str_starts_with($key, Resource::METADATA_PREFIX), ARRAY_FILTER_USE_KEY)]); $this->leaseId = $property['leaseId'] ?? null; $this->sequenceNumberAction = $property['sequenceNumberAction'] ?? null; $this->origin = $property['Origin'] ?? null; diff --git a/src/BlobStorage/Entities/Blob/BlobTag.php b/src/BlobStorage/Entities/Blob/BlobTag.php index 89166b3..7912199 100644 --- a/src/BlobStorage/Entities/Blob/BlobTag.php +++ b/src/BlobStorage/Entities/Blob/BlobTag.php @@ -5,6 +5,7 @@ namespace Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob; use DateTimeImmutable; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Contracts\Xmlable; use Xray\AzureStoragePhpSdk\Converter\XmlConverter; use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; @@ -41,8 +42,8 @@ public function __construct(array $tags = [], array $options = []) $this->contentType = $options['Content-Type'] ?? null; $this->vary = $options['Vary'] ?? null; $this->server = $options['Server'] ?? null; - $this->xMsRequestId = $options['x-ms-request-id'] ?? null; - $this->xMsVersion = $options['x-ms-version'] ?? null; + $this->xMsRequestId = $options[Resource::REQUEST_ID] ?? null; + $this->xMsVersion = $options[Resource::AUTH_VERSION] ?? null; $this->date = isset($options['Date']) ? new DateTimeImmutable($options['Date']) : null; $this->tags = $this->mountTags($tags); @@ -83,7 +84,7 @@ public function toXml(): string ]; } - return (new XmlConverter())->convert([ + return (azure_app(XmlConverter::class))->convert([ 'Tags' => [ 'TagSet' => $tags, ], diff --git a/src/BlobStorage/Entities/Blob/Blobs.php b/src/BlobStorage/Entities/Blob/Blobs.php index 28f2709..7216e6e 100644 --- a/src/BlobStorage/Entities/Blob/Blobs.php +++ b/src/BlobStorage/Entities/Blob/Blobs.php @@ -32,7 +32,7 @@ public function __construct(protected BlobManager $manager, array $blobs = []) protected function generateBlobsList(array $blobs): array { return array_map( - fn (array $blob) => (new Blob($blob))->setManager($this->manager), + fn (array $blob) => (azure_app(Blob::class, ['blob' => $blob]))->setManager($this->manager), $blobs, ); } diff --git a/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevel.php b/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevel.php index c2305f8..fe5285e 100644 --- a/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevel.php +++ b/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevel.php @@ -65,7 +65,7 @@ public function toArray(): array public function toXML(): string { - return (new XmlConverter())->convert([ + return azure_app(XmlConverter::class)->convert([ 'SignedIdentifiers' => ['SignedIdentifier' => $this->toArray()], ]); } diff --git a/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevels.php b/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevels.php index 4bbc7fa..4087c0c 100644 --- a/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevels.php +++ b/src/BlobStorage/Entities/Container/AccessLevel/ContainerAccessLevels.php @@ -42,6 +42,6 @@ public function __construct(protected ContainerAccessLevelManager $manager, arra */ protected function mapContainerAccessLevel(array $level): ContainerAccessLevel { - return new ContainerAccessLevel($level); + return azure_app(ContainerAccessLevel::class, ['containerAccessLevel' => $level]); } } diff --git a/src/BlobStorage/Entities/Container/Container.php b/src/BlobStorage/Entities/Container/Container.php index d154eba..f7abccc 100644 --- a/src/BlobStorage/Entities/Container/Container.php +++ b/src/BlobStorage/Entities/Container/Container.php @@ -42,7 +42,7 @@ public function __construct(array $container) $this->name = $name; $this->deleted = to_boolean($container['Deleted'] ?? false); $this->version = $container['Version'] ?? ''; - $this->properties = new Properties($container['Properties'] ?? []); + $this->properties = azure_app(Properties::class, ['property' => $container['Properties'] ?? []]); } public function listAccessLevels(): ContainerAccessLevels @@ -91,6 +91,6 @@ public function blobs(): BlobManager { $this->ensureManagerIsConfigured(); - return new BlobManager($this->getManager()->getRequest(), $this->name); + return azure_app(BlobManager::class, ['containerName' => $this->name]); } } diff --git a/src/BlobStorage/Entities/Container/Containers.php b/src/BlobStorage/Entities/Container/Containers.php index bad5b00..8e67e66 100644 --- a/src/BlobStorage/Entities/Container/Containers.php +++ b/src/BlobStorage/Entities/Container/Containers.php @@ -32,7 +32,7 @@ public function __construct(protected ContainerManager $manager, array $containe protected function generateContainersList(array $containers): array { return array_map( - fn (array $container) => (new Container($container))->setManager($this->manager), + fn (array $container) => azure_app(Container::class, ['container' => $container])->setManager($this->manager), $containers, ); } diff --git a/src/BlobStorage/Enums/AccessTokenPermission.php b/src/BlobStorage/Enums/AccessTokenPermission.php new file mode 100644 index 0000000..384d3bf --- /dev/null +++ b/src/BlobStorage/Enums/AccessTokenPermission.php @@ -0,0 +1,12 @@ +request->getConfig()->parser->parse($response); - return new BlobProperty($parsed ?? []); + return azure_app(BlobProperty::class, ['blobProperty' => $parsed ?? []]); } /** @param array $options */ diff --git a/src/BlobStorage/Managers/AccountManager.php b/src/BlobStorage/Managers/AccountManager.php index 6858e72..5f98135 100644 --- a/src/BlobStorage/Managers/AccountManager.php +++ b/src/BlobStorage/Managers/AccountManager.php @@ -50,17 +50,17 @@ public function information(array $options = []): AccountInformation * Date: ?string * } $response * */ - return new AccountInformation($response); + return azure_app(AccountInformation::class, ['accountInformation' => $response]); } public function storageProperties(): StoragePropertyManager { - return new StoragePropertyManager($this->request); + return azure_app(StoragePropertyManager::class); } public function preflightBlobRequest(): PreflightBlobRequestManager { - return new PreflightBlobRequestManager($this->request); + return azure_app(PreflightBlobRequestManager::class); } /** @param array $options */ @@ -82,13 +82,12 @@ public function blobServiceStats(array $options = []): GeoReplication /** @var array{GeoReplication: array{Status: string, LastSyncTime: string}} $parsed */ $parsed = $this->request->getConfig()->parser->parse($response); - return new GeoReplication($parsed['GeoReplication']); + return azure_app(GeoReplication::class, ['geoReplication' => $parsed['GeoReplication']]); } /** @param array $options */ public function userDelegationKey(KeyInfo $keyInfo, array $options = []): UserDelegationKey { - # FIX: Needs other authentication (Microsoft Entra ID) try { $response = $this->request ->withOptions($options) @@ -101,9 +100,9 @@ public function userDelegationKey(KeyInfo $keyInfo, array $options = []): UserDe } // @codeCoverageIgnoreEnd - /** @var array{UserDelegationKey: array{SignedOid: string, SignedTid: string, SignedStart: string, SignedExpiry: string, SignedService: string, SignedVersion: string, Value: string}} $parsed */ + /** @var array{SignedOid: string, SignedTid: string, SignedStart: string, SignedExpiry: string, SignedService: string, SignedVersion: string, Value: string} $parsed */ $parsed = $this->request->getConfig()->parser->parse($response); - return new UserDelegationKey($parsed['UserDelegationKey']); + return azure_app(UserDelegationKey::class, ['userDelegationKey' => $parsed]); } } diff --git a/src/BlobStorage/Managers/Blob/BlobLeaseManager.php b/src/BlobStorage/Managers/Blob/BlobLeaseManager.php index 3024844..890ec72 100644 --- a/src/BlobStorage/Managers/Blob/BlobLeaseManager.php +++ b/src/BlobStorage/Managers/Blob/BlobLeaseManager.php @@ -15,8 +15,8 @@ class BlobLeaseManager implements Manager { public function __construct( protected Request $request, - protected string $container, - protected string $blob + protected string $containerName, + protected string $blobName, ) { // } @@ -30,7 +30,7 @@ public function acquire(int $duration = -1, ?string $leaseId = null): BlobLease Resource::LEASE_ID => $leaseId, ]))->getHeaders(); - return (new BlobLease($headers)) + return azure_app(BlobLease::class, ['blobLease' => $headers]) ->setManager($this); } @@ -42,7 +42,7 @@ public function renew(string $leaseId): BlobLease Resource::LEASE_ID => $leaseId, ])->getHeaders(); - return (new BlobLease($headers)) + return azure_app(BlobLease::class, ['blobLease' => $headers]) ->setManager($this); } @@ -55,7 +55,7 @@ public function change(string $fromLeaseId, string $toLeaseId): BlobLease Resource::LEASE_PROPOSED_ID => $toLeaseId, ])->getHeaders(); - return (new BlobLease($headers)) + return azure_app(BlobLease::class, ['blobLease' => $headers]) ->setManager($this); } @@ -67,7 +67,7 @@ public function release(string $leaseId): BlobLease Resource::LEASE_ID => $leaseId, ])->getHeaders(); - return (new BlobLease($headers)) + return azure_app(BlobLease::class, ['blobLease' => $headers]) ->setManager($this); } @@ -79,7 +79,7 @@ public function break(?string $leaseId = null): BlobLease Resource::LEASE_ID => $leaseId, ]))->getHeaders(); - return (new BlobLease($headers)) + return azure_app(BlobLease::class, ['blobLease' => $headers]) ->setManager($this); } @@ -89,9 +89,11 @@ protected function request(array $headers): Response try { return $this->request ->withHeaders($headers) - ->put("{$this->container}/{$this->blob}?comp=lease&resttype=blob"); + ->put("{$this->containerName}/{$this->blobName}?comp=lease&resttype=blob"); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd } } diff --git a/src/BlobStorage/Managers/Blob/BlobManager.php b/src/BlobStorage/Managers/Blob/BlobManager.php index d02b013..4d813ab 100644 --- a/src/BlobStorage/Managers/Blob/BlobManager.php +++ b/src/BlobStorage/Managers/Blob/BlobManager.php @@ -6,11 +6,14 @@ use DateTime; use DateTimeImmutable; +use DateTimeInterface; use Psr\Http\Client\RequestExceptionInterface; -use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\{Blob, Blobs, File}; -use Xray\AzureStoragePhpSdk\BlobStorage\Enums\{BlobIncludeOption, BlobType, ExpirationOption}; +use Xray\AzureStoragePhpSdk\Authentication\SharedAccessSignature\UserDelegationSas; +use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\{Blob, Blobs}; +use Xray\AzureStoragePhpSdk\BlobStorage\Enums\{AccessTokenPermission, BlobIncludeOption, BlobType, ExpirationOption}; use Xray\AzureStoragePhpSdk\BlobStorage\Queries\BlobTagQuery; use Xray\AzureStoragePhpSdk\BlobStorage\Resource; +use Xray\AzureStoragePhpSdk\BlobStorage\Resources\File; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Contracts\Manager; use Xray\AzureStoragePhpSdk\Exceptions\{InvalidArgumentException, RequestException}; @@ -19,10 +22,12 @@ * @phpstan-import-type BlobType from Blob as BlobTypeStan * @phpstan-import-type FileType from File */ -readonly class BlobManager implements Manager +class BlobManager implements Manager { - public function __construct(protected Request $request, protected string $containerName) - { + public function __construct( + protected readonly Request $request, + protected readonly string $containerName, + ) { // } @@ -57,7 +62,7 @@ public function list(array $options = [], array $includes = []): Blobs /** @var array{Blobs?: array{Blob: BlobTypeStan|BlobTypeStan[]}} $parsed */ $parsed = $this->request->getConfig()->parser->parse($response); - return new Blobs($this, $parsed['Blobs']['Blob'] ?? []); + return azure_app(Blobs::class, ['blobs' => $parsed['Blobs']['Blob'] ?? [], 'containerName' => $this->containerName]); } /** @@ -68,21 +73,23 @@ public function list(array $options = [], array $includes = []): Blobs public function findByTag(array $options = []): BlobTagQuery { /** @var BlobTagQuery */ - return (new BlobTagQuery($this)) + return azure_app(BlobTagQuery::class, ['manager' => $this]) ->whenBuild(function (string $query) use ($options): Blobs { try { $response = $this->request ->withOptions($options) ->get("{$this->containerName}/?restype=container&comp=blobs&where={$query}") ->getBody(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd /** @var array{Blobs?: array{Blob: BlobTypeStan|BlobTypeStan[]}} $parsed */ $parsed = $this->request->getConfig()->parser->parse($response); - return new Blobs($this, $parsed['Blobs']['Blob'] ?? []); + return azure_app(Blobs::class, ['blobs' => $parsed['Blobs']['Blob'] ?? [], 'containerName' => $this->containerName]); }); } @@ -109,7 +116,7 @@ public function get(string $blobName, array $options = []): File $headers = (array) $headers; array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return new File($blobName, $content, $headers); + return azure_app(File::class, ['name' => $blobName, 'content' => $content, 'options' => $headers]); } /** @param array $options */ @@ -120,13 +127,13 @@ public function putBlock(File $file, array $options = []): bool ->withOptions($options) ->withHeaders([ Resource::BLOB_TYPE => BlobType::BLOCK->value, - Resource::BLOB_CONTENT_MD5 => $file->contentMD5, - Resource::BLOB_CONTENT_TYPE => $file->contentType, - Resource::CONTENT_MD5 => $file->contentMD5, - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_LENGTH => $file->contentLength, + Resource::BLOB_CONTENT_MD5 => $file->getContentMD5(), + Resource::BLOB_CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_MD5 => $file->getContentMD5(), + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_LENGTH => $file->getContentLength(), ]) - ->put("{$this->containerName}/{$file->name}?resttype=blob", $file->content) + ->put("{$this->containerName}/{$file->getFilename()}?resttype=blob", $file->getContent()) ->isCreated(); // @codeCoverageIgnoreStart @@ -154,21 +161,23 @@ public function setExpiry(string $blobName, ExpirationOption $expirationOption, ])) ->put("{$this->containerName}/{$blobName}?resttype=blob&comp=expiry") ->isOk(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd } /** * @param boolean $force If true, Delete the base blob and all of its snapshots. */ - public function delete(string $blobName, null|DateTimeImmutable|string $snapshot = null, bool $force = false): bool + public function delete(string $blobName, null|DateTime|string $snapshot = null, bool $force = false): bool { - if ($snapshot instanceof DateTimeImmutable) { + if ($snapshot instanceof DateTime) { $snapshot = convert_to_RFC3339_micro($snapshot); } - $snapshotHeader = $snapshot ? sprintf('?snapshot=%s', urlencode($snapshot)) : ''; + $snapshotHeader = $snapshot ? sprintf('&snapshot=%s', urlencode($snapshot)) : ''; $deleteSnapshotHeader = $snapshot ? sprintf('&%s=only', Resource::DELETE_SNAPSHOTS) : ''; @@ -180,9 +189,11 @@ public function delete(string $blobName, null|DateTimeImmutable|string $snapshot return $this->request ->delete("{$this->containerName}/{$blobName}?resttype=blob{$snapshotHeader}{$deleteSnapshotHeader}") ->isAccepted(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd } public function restore(string $blobName): bool @@ -191,9 +202,11 @@ public function restore(string $blobName): bool return $this->request ->put("{$this->containerName}/{$blobName}?comp=undelete&resttype=blob") ->isOk(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd } public function createSnapshot(string $blobName): bool @@ -202,15 +215,17 @@ public function createSnapshot(string $blobName): bool return $this->request ->put("{$this->containerName}/{$blobName}?comp=snapshot&resttype=blob") ->isCreated(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd } /** @param array $options */ - public function copy(string $sourceCopy, string $blobName, array $options = [], null|DateTimeImmutable|string $snapshot = null): bool + public function copy(string $sourceCopy, string $blobName, array $options = [], null|DateTime|string $snapshot = null): bool { - if ($snapshot instanceof DateTimeImmutable) { + if ($snapshot instanceof DateTime) { $snapshot = convert_to_RFC3339_micro($snapshot); } @@ -226,35 +241,61 @@ public function copy(string $sourceCopy, string $blobName, array $options = [], ]) ->put("{$this->containerName}/{$blobName}?resttype=blob") ->isAccepted(); + // @codeCoverageIgnoreStart } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); } + // @codeCoverageIgnoreEnd + } + + public function temporaryUrl(string $blobName, string|int|DateTimeInterface $expiresAt): string + { + /** @var DateTimeImmutable $expires */ + $expires = match(true) { + $expiresAt instanceof DateTime => DateTimeImmutable::createFromMutable($expiresAt), + is_int($expiresAt) => DateTimeImmutable::createFromFormat('U', (string)$expiresAt), + is_string($expiresAt) => new DateTimeImmutable($expiresAt), + default => $expiresAt, + }; + + if ($expires <= new DateTimeImmutable()) { + throw InvalidArgumentException::create('Expiration time must be in the future'); + } + + $resource = "/{$this->containerName}/{$blobName}"; + + $token = azure_app(UserDelegationSas::class, ['request' => $this->request->withResource($resource)]) + ->buildTokenUrl(AccessTokenPermission::READ, $expires); + + $uri = $this->request->uri("{$this->containerName}/{$blobName}"); + + return $uri . $token; } public function lease(string $blobName): BlobLeaseManager { - return new BlobLeaseManager($this->request, $this->containerName, $blobName); + return azure_app(BlobLeaseManager::class, ['containerName' => $this->containerName, 'blobName' => $blobName]); } public function pages(): BlobPageManager { - return (new BlobPageManager($this->request, $this->containerName)) + return azure_app(BlobPageManager::class, ['containerName' => $this->containerName]) ->setManager($this); } public function properties(string $blobName): BlobPropertyManager { - return new BlobPropertyManager($this->request, $this->containerName, $blobName); + return azure_app(BlobPropertyManager::class, ['containerName' => $this->containerName, 'blobName' => $blobName]); } public function metadata(string $blobName): BlobMetadataManager { - return new BlobMetadataManager($this->request, $this->containerName, $blobName); + return azure_app(BlobMetadataManager::class, ['containerName' => $this->containerName, 'blobName' => $blobName]); } public function tags(string $blobName): BlobTagManager { - return new BlobTagManager($this->request, $this->containerName, $blobName); + return azure_app(BlobTagManager::class, ['containerName' => $this->containerName, 'blobName' => $blobName]); } protected function validateExpirationTime(ExpirationOption $expirationOption, null|int|DateTime $expiryTime = null): void diff --git a/src/BlobStorage/Managers/Blob/BlobMetadataManager.php b/src/BlobStorage/Managers/Blob/BlobMetadataManager.php index 914d8ad..20831aa 100644 --- a/src/BlobStorage/Managers/Blob/BlobMetadataManager.php +++ b/src/BlobStorage/Managers/Blob/BlobMetadataManager.php @@ -47,7 +47,7 @@ public function get(array $options = []): BlobMetadata ARRAY_FILTER_USE_KEY, ); - return new BlobMetadata($metadata, (array) $headers); + return azure_app(BlobMetadata::class, ['metadata' => $metadata, 'options' => (array) $headers]); } /** @param array $options */ diff --git a/src/BlobStorage/Managers/Blob/BlobPageManager.php b/src/BlobStorage/Managers/Blob/BlobPageManager.php index ad777b3..1d655e1 100644 --- a/src/BlobStorage/Managers/Blob/BlobPageManager.php +++ b/src/BlobStorage/Managers/Blob/BlobPageManager.php @@ -5,9 +5,9 @@ namespace Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob; use Psr\Http\Client\RequestExceptionInterface; -use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\File; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\BlobType; use Xray\AzureStoragePhpSdk\BlobStorage\Resource; +use Xray\AzureStoragePhpSdk\BlobStorage\Resources\File; use Xray\AzureStoragePhpSdk\Concerns\HasManager; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Contracts\Manager; @@ -53,17 +53,17 @@ public function create(string $name, int $length, array $options = [], array $he /** @param array $options */ public function append(File $file, int $startPage, ?int $endPage = null, array $options = []): bool { - $this->validatePageBytesBoundary($file->contentLength); + $this->validatePageBytesBoundary($file->getContentLength()); ['startByte' => $startByte] = $this->getPageRange($startPage); - $endByte = $startByte + $file->contentLength - 1; + $endByte = $startByte + $file->getContentLength() - 1; if ($endPage) { ['endByte' => $endByte] = $this->getPageRange($endPage); } - $this->validatePageSize($startByte, $endByte, $file->contentLength); + $this->validatePageSize($startByte, $endByte, $file->getContentLength()); try { return $this->request @@ -71,11 +71,11 @@ public function append(File $file, int $startPage, ?int $endPage = null, array $ ->withHeaders([ Resource::PAGE_WRITE => 'update', Resource::RANGE => "bytes={$startByte}-{$endByte}", - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_LENGTH => $file->contentLength, - Resource::CONTENT_MD5 => $file->contentMD5, + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_LENGTH => $file->getContentLength(), + Resource::CONTENT_MD5 => $file->getContentMD5(), ]) - ->put("{$this->containerName}/{$file->name}?resttype=blob&comp=page", $file->content) + ->put("{$this->containerName}/{$file->getFilename()}?resttype=blob&comp=page", $file->getContent()) ->isCreated(); // @codeCoverageIgnoreStart @@ -88,12 +88,12 @@ public function append(File $file, int $startPage, ?int $endPage = null, array $ /** @param array $options */ public function put(File $file, array $options = []): bool { - $this->validatePageBytesBoundary($file->contentLength); + $this->validatePageBytesBoundary($file->getContentLength()); try { - $this->create($file->name, $file->contentLength, $options, [ - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_MD5 => $file->contentMD5, + $this->create($file->getFilename(), $file->getContentLength(), $options, [ + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_MD5 => $file->getContentMD5(), ]); return $this->append($file, 1, options: $options); @@ -140,7 +140,7 @@ public function clearAll(string $name, array $options = []): bool $file = $this->getManager()->get($name); - return $this->clear($name, 1, (int)($file->contentLength / self::PAGE_SIZE_BYTES), $options); + return $this->clear($name, 1, (int)($file->getContentLength() / self::PAGE_SIZE_BYTES), $options); } /** @return array{startByte: int, endByte: int} */ diff --git a/src/BlobStorage/Managers/Blob/BlobPropertyManager.php b/src/BlobStorage/Managers/Blob/BlobPropertyManager.php index 04a7336..b22895f 100644 --- a/src/BlobStorage/Managers/Blob/BlobPropertyManager.php +++ b/src/BlobStorage/Managers/Blob/BlobPropertyManager.php @@ -42,7 +42,7 @@ public function get(array $options = []): BlobProperty $headers = (array) $headers; array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return new BlobProperty($headers); + return azure_app(BlobProperty::class, ['property' => $headers]); } /** @param array $options */ diff --git a/src/BlobStorage/Managers/Blob/BlobTagManager.php b/src/BlobStorage/Managers/Blob/BlobTagManager.php index 0ebdcdc..146c4a6 100644 --- a/src/BlobStorage/Managers/Blob/BlobTagManager.php +++ b/src/BlobStorage/Managers/Blob/BlobTagManager.php @@ -52,7 +52,7 @@ public function get(array $options = []): BlobTag array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return new BlobTag($tags, $headers); + return azure_app(BlobTag::class, ['tags' => $tags, 'options' => $headers]); } /** @param array $options */ diff --git a/src/BlobStorage/Managers/Container/ContainerAccessLevelManager.php b/src/BlobStorage/Managers/Container/ContainerAccessLevelManager.php index 1b0884e..0157df4 100644 --- a/src/BlobStorage/Managers/Container/ContainerAccessLevelManager.php +++ b/src/BlobStorage/Managers/Container/ContainerAccessLevelManager.php @@ -47,7 +47,7 @@ public function list(string $container, array $options = []): ContainerAccessLev /** @var array>> */ $parsed = $this->request->getConfig()->parser->parse($response); - return new ContainerAccessLevels($this, $parsed['SignedIdentifier'] ?? []); + return azure_app(ContainerAccessLevels::class, ['levels' => $parsed['SignedIdentifier'] ?? []]); } /** diff --git a/src/BlobStorage/Managers/Container/ContainerLeaseManager.php b/src/BlobStorage/Managers/Container/ContainerLeaseManager.php index f63caac..7a697ad 100644 --- a/src/BlobStorage/Managers/Container/ContainerLeaseManager.php +++ b/src/BlobStorage/Managers/Container/ContainerLeaseManager.php @@ -34,7 +34,7 @@ public function acquire(int $duration = -1, ?string $leaseId = null): ContainerL array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return (new ContainerLease($headers)) + return azure_app(ContainerLease::class, ['containerLease' => $headers]) ->setManager($this); } @@ -48,7 +48,7 @@ public function renew(string $leaseId): ContainerLease array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return (new ContainerLease($headers)) + return azure_app(ContainerLease::class, ['containerLease' => $headers]) ->setManager($this); } @@ -63,7 +63,7 @@ public function change(string $fromLeaseId, string $toLeaseId): ContainerLease array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return (new ContainerLease($headers)) + return azure_app(ContainerLease::class, ['containerLease' => $headers]) ->setManager($this); } @@ -77,7 +77,7 @@ public function release(string $leaseId): ContainerLease array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return (new ContainerLease($headers)) + return azure_app(ContainerLease::class, ['containerLease' => $headers]) ->setManager($this); } @@ -91,7 +91,7 @@ public function break(?string $leaseId = null): ContainerLease array_walk($headers, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line - return (new ContainerLease($headers)) + return azure_app(ContainerLease::class, ['containerLease' => $headers]) ->setManager($this); } diff --git a/src/BlobStorage/Managers/Container/ContainerMetadataManager.php b/src/BlobStorage/Managers/Container/ContainerMetadataManager.php index 94bcc63..5146b8d 100644 --- a/src/BlobStorage/Managers/Container/ContainerMetadataManager.php +++ b/src/BlobStorage/Managers/Container/ContainerMetadataManager.php @@ -10,7 +10,7 @@ use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Contracts\Manager; -use Xray\AzureStoragePhpSdk\Exceptions\{RequestException}; +use Xray\AzureStoragePhpSdk\Exceptions\RequestException; readonly class ContainerMetadataManager implements Manager { @@ -45,7 +45,7 @@ public function get(string $container, array $options = []): ContainerMetadata array_walk($response, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line /** @var array $response */ - return new ContainerMetadata($response); + return azure_app(ContainerMetadata::class, ['containerMetadata' => $response]); } /** diff --git a/src/BlobStorage/Managers/ContainerManager.php b/src/BlobStorage/Managers/ContainerManager.php index 474a87a..f088af7 100644 --- a/src/BlobStorage/Managers/ContainerManager.php +++ b/src/BlobStorage/Managers/ContainerManager.php @@ -17,31 +17,31 @@ use Xray\AzureStoragePhpSdk\Concerns\HasRequestShared; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Contracts\{Manager, RequestShared}; -use Xray\AzureStoragePhpSdk\Exceptions\{RequestException}; +use Xray\AzureStoragePhpSdk\Exceptions\RequestException; /** * @phpstan-import-type ContainerType from Container * @implements RequestShared */ -readonly class ContainerManager implements Manager, RequestShared +class ContainerManager implements Manager, RequestShared { /** @use HasRequestShared */ use HasRequestShared; use ValidateContainerName; - public function __construct(protected Request $request) + public function __construct(protected readonly Request $request) { // } public function accessLevel(): ContainerAccessLevelManager { - return new ContainerAccessLevelManager($this->request); + return azure_app(ContainerAccessLevelManager::class); } public function metadata(): ContainerMetadataManager { - return new ContainerMetadataManager($this->request); + return azure_app(ContainerMetadataManager::class); } /** @@ -65,7 +65,7 @@ public function getProperties(string $container, array $options = []): Container array_walk($response, fn (string|array &$value) => $value = is_array($value) ? current($value) : $value); // @phpstan-ignore-line /** @var array $response */ - return new ContainerProperties($response); + return azure_app(ContainerProperties::class, ['containerProperty' => $response]); } /** @param array $options */ @@ -86,14 +86,14 @@ public function list(array $options = [], bool $withDeleted = false): Containers /** @var array{Containers?: array{Container: ContainerType|ContainerType[]}} $parsed */ $parsed = $this->request->getConfig()->parser->parse($response); - return new Containers($this, $parsed['Containers']['Container'] ?? []); + return azure_app(Containers::class, ['containers' => $parsed['Containers']['Container'] ?? []]); } public function lease(string $name): ContainerLeaseManager { $this->validateContainerName($name); - return new ContainerLeaseManager($this->request, $name); + return azure_app(ContainerLeaseManager::class, ['container' => $name]); } public function create(string $name): bool diff --git a/src/BlobStorage/Queries/BlobTagQuery.php b/src/BlobStorage/Queries/BlobTagQuery.php index cf7bd2e..998fe86 100644 --- a/src/BlobStorage/Queries/BlobTagQuery.php +++ b/src/BlobStorage/Queries/BlobTagQuery.php @@ -6,7 +6,7 @@ use Closure; use Xray\AzureStoragePhpSdk\Contracts\Manager; -use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; +use Xray\AzureStoragePhpSdk\Exceptions\{InvalidArgumentException, RequiredFieldException}; /** * @template TManager of Manager @@ -74,7 +74,7 @@ public function build(): object protected function validateOperator(string $operator): void { if (!in_array($operator, ['=', '>', '>=', '<', '<='])) { - throw new \InvalidArgumentException("Invalid operator: {$operator}"); + throw InvalidArgumentException::create("Invalid operator: {$operator}"); } } } diff --git a/src/BlobStorage/Entities/Blob/File.php b/src/BlobStorage/Resources/File.php similarity index 50% rename from src/BlobStorage/Entities/Blob/File.php rename to src/BlobStorage/Resources/File.php index 3ad54f1..41ae24d 100644 --- a/src/BlobStorage/Entities/Blob/File.php +++ b/src/BlobStorage/Resources/File.php @@ -2,58 +2,33 @@ declare(strict_types=1); -namespace Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob; +namespace Xray\AzureStoragePhpSdk\BlobStorage\Resources; use DateTimeImmutable; -use Xray\AzureStoragePhpSdk\Exceptions\{CouldNotCreateTempFileException, InvalidFileMimeTypeException}; +use Xray\AzureStoragePhpSdk\Concerns\{HasFileMethods, HasFileProperties}; +use Xray\AzureStoragePhpSdk\Exceptions\{ + CouldNotCreateTempFileException, + InvalidArgumentException, + InvalidFileMimeTypeException, +}; /** * @phpstan-type FileType array{Content-Length?: string, Content-Type?: string, Content-MD5?: string, Last-Modified?: string, Accept-Ranges?: string, ETag?: string, Vary?: string, Server?: string, x-ms-request-id?: string, x-ms-version?: string, x-ms-creation-time?: string, x-ms-lease-status?: string, x-ms-lease-state?: string, x-ms-blob-type?: string, x-ms-server-encrypted?: bool, Date?: string} - * @suppressWarnings(PHPMD.TooManyFields) */ -final readonly class File +final class File { - public string $content; - - public string $name; - - public int $contentLength; - - public string $contentType; - - public string $contentMD5; - - public DateTimeImmutable $lastModified; - - public string $acceptRanges; - - public string $eTag; - - public string $vary; - - public string $server; - - public string $xMsRequestId; - - public DateTimeImmutable $xMsVersion; - - public DateTimeImmutable $xMsCreationTime; - - public string $xMsLeaseStatus; - - public string $xMsLeaseState; - - public string $xMsBlobType; - - public bool $xMsServerEncrypted; - - public DateTimeImmutable $date; + use HasFileProperties; + use HasFileMethods; /** @param FileType $options */ - public function __construct(string $name, string $content, array $options = []) + public function __construct(string $name, string $content = '', array $options = []) { - $this->content = $content; + if (!$name) { + throw InvalidArgumentException::create('[name] cannot be empty'); + } + $this->name = $name; + $this->content = $content; $this->contentLength = (int) ($options['Content-Length'] ?? strlen($this->content)); $this->contentType = $options['Content-Type'] ?? $this->detectContentType(); @@ -64,7 +39,7 @@ public function __construct(string $name, string $content, array $options = []) $this->vary = $options['Vary'] ?? ''; $this->server = $options['Server'] ?? ''; $this->xMsRequestId = $options['x-ms-request-id'] ?? ''; - $this->xMsVersion = new DateTimeImmutable($options['x-ms-version'] ?? 'now'); + $this->xMsVersion = $options['x-ms-version'] ?? ''; $this->xMsCreationTime = new DateTimeImmutable($options['x-ms-creation-time'] ?? 'now'); $this->xMsLeaseStatus = $options['x-ms-lease-status'] ?? ''; $this->xMsLeaseState = $options['x-ms-lease-state'] ?? ''; @@ -73,44 +48,21 @@ public function __construct(string $name, string $content, array $options = []) $this->date = new DateTimeImmutable($options['Date'] ?? 'now'); } - public function stream(): void - { - header("Content-Disposition: inline; filename=\"{$this->name}\""); - - $this->handleFileToDownloadOrStream(); - } - - public function download(): void - { - header("Content-Disposition: attachment; filename=\"{$this->name}\""); - - $this->handleFileToDownloadOrStream(); - } - - protected function handleFileToDownloadOrStream(): void - { - header("Content-Type: {$this->contentType}"); - header("Content-Length: {$this->contentLength}"); - header('Cache-Control: no-cache, must-revalidate'); - header('Expires: 0'); - - echo $this->content; - } - - private function detectContentType(): string + protected function detectContentType(): string { if (($file = tmpfile()) === false) { - throw CouldNotCreateTempFileException::create('Could not create temporary file'); + throw CouldNotCreateTempFileException::create('Could not create temporary file'); // @codeCoverageIgnore } - fwrite($file, $this->content); - - $mimeType = mime_content_type($file); - - fclose($file); + try { + fwrite($file, $this->content); + $mimeType = mime_content_type($file); + } finally { + fclose($file); + } if (!$mimeType) { - throw InvalidFileMimeTypeException::create(); + throw InvalidFileMimeTypeException::create(); // @codeCoverageIgnore } return $mimeType; diff --git a/src/BlobStorage/SignatureResource.php b/src/BlobStorage/SignatureResource.php new file mode 100644 index 0000000..ac97361 --- /dev/null +++ b/src/BlobStorage/SignatureResource.php @@ -0,0 +1,58 @@ +content; + } + + public function getFilename(): string + { + return $this->name; + } + + public function getContentLength(): int + { + return $this->contentLength; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getContentMD5(): string + { + return $this->contentMD5; + } + + public function getLastModified(): DateTimeImmutable + { + return $this->lastModified; + } + + public function getAcceptRanges(): string + { + return $this->acceptRanges; + } + + public function getETag(): string + { + return $this->eTag; + } + + public function getVary(): string + { + return $this->vary; + } + + public function getServer(): string + { + return $this->server; + } + + public function getRequestId(): string + { + return $this->xMsRequestId; + } + + public function getVersion(): string + { + return $this->xMsVersion; + } + + public function getCreationTime(): DateTimeImmutable + { + return $this->xMsCreationTime; + } + + public function getLeaseStatus(): string + { + return $this->xMsLeaseStatus; + } + + public function getLeaseState(): string + { + return $this->xMsLeaseState; + } + + public function getBlobType(): string + { + return $this->xMsBlobType; + } + + public function getServerEncrypted(): bool + { + return $this->xMsServerEncrypted; + } + + public function getDate(): DateTimeImmutable + { + return $this->date; + } +} diff --git a/src/Concerns/HasFileProperties.php b/src/Concerns/HasFileProperties.php new file mode 100644 index 0000000..6089b89 --- /dev/null +++ b/src/Concerns/HasFileProperties.php @@ -0,0 +1,46 @@ +getFilename()}\""); + header("Content-Type: {$file->getContentType()}"); + header("Content-Length: {$file->getContentLength()}"); + header('Cache-Control: no-cache, must-revalidate'); + header("Expires: {$expires}"); + + return with($file->getContent(), function (string $content): void { + if (!is_running_in_console()) { + echo $content; // @codeCoverageIgnore + } + }); + } + + /** @phpstan-assert null|positive-int $expires */ + protected static function validateExpiresResponse(?int $expires): void + { + if (is_int($expires) && $expires < 0) { + throw InvalidArgumentException::create('Expires cannot be less than 0.'); + } + } +} diff --git a/src/Concerns/Http/HasAuthenticatedRequest.php b/src/Concerns/Http/HasAuthenticatedRequest.php new file mode 100644 index 0000000..03571ee --- /dev/null +++ b/src/Concerns/Http/HasAuthenticatedRequest.php @@ -0,0 +1,32 @@ +auth; + } + + public function withAuthentication(bool $shouldAuthenticate = true): static + { + $this->shouldAuthenticate = $shouldAuthenticate; + + return $this; + } + + public function withoutAuthentication(): static + { + return $this->withAuthentication(false); + } +} diff --git a/src/Concerns/Http/HasSharingMethods.php b/src/Concerns/Http/HasSharingMethods.php new file mode 100644 index 0000000..44e0078 --- /dev/null +++ b/src/Concerns/Http/HasSharingMethods.php @@ -0,0 +1,67 @@ +verb ?? HttpVerb::GET; + } + + public function withVerb(HttpVerb $verb): static + { + $this->verb = $verb; + + return $this; + } + + public function getBody(): string + { + return $this->body ?? ''; + } + + public function withBody(string $body): static + { + $this->body = $body; + + return $this; + } + + public function getResource(): string + { + return $this->resource ?? ''; + } + + public function withResource(string $resource): static + { + $this->resource = $resource; + + return $this; + } + + public function getHttpHeaders(): Headers + { + return $this->httpHeaders ?? azure_app(Headers::class); + } + + public function withHttpHeaders(Headers $headers): static + { + $this->httpHeaders = $headers; + + return $this; + } +} diff --git a/src/Concerns/UseCurrentHttpDate.php b/src/Concerns/UseCurrentHttpDate.php new file mode 100644 index 0000000..d7a5212 --- /dev/null +++ b/src/Concerns/UseCurrentHttpDate.php @@ -0,0 +1,13 @@ + $options */ public function withOptions(array $options = []): static; diff --git a/src/Contracts/Http/Sharable.php b/src/Contracts/Http/Sharable.php new file mode 100644 index 0000000..bf106af --- /dev/null +++ b/src/Contracts/Http/Sharable.php @@ -0,0 +1,27 @@ + */ protected array $options = []; @@ -20,17 +32,19 @@ class Request implements RequestContract /** @var array */ protected array $headers = []; - protected ?Closure $usingAccountCallback = null; - - protected bool $shouldAuthenticate = true; - public function __construct( - public Config $config, + protected readonly Auth $auth, + ?Config $config = null, ?ClientInterface $client = null, - public string $protocol = 'https', - public string $baseDomain = 'blob.core.windows.net' + ?string $protocol = null, + ?string $domain = null, ) { - $this->client = $client ?? new Client(); + validate_protocol($protocol ??= 'https'); + + $this->client = $client ?? azure_app(Client::class); + $this->config = $config ?? azure_app(Config::class); + $this->protocol = $protocol; + $this->domain = $domain ?? 'blob.core.windows.net'; } public function usingAccount(Closure $callback): static @@ -45,18 +59,6 @@ public function getConfig(): Config return $this->config; } - public function withAuthentication(bool $shouldAuthenticate = true): static - { - $this->shouldAuthenticate = $shouldAuthenticate; - - return $this; - } - - public function withoutAuthentication(): static - { - return $this->withAuthentication(false); - } - /** @param array $options */ public function withOptions(array $options = []): static { @@ -137,7 +139,7 @@ public function options(string $endpoint): ResponseContract public function uri(?string $endpoint = null): string { - $account = $this->config->auth->getAccount(); + $account = $this->auth->getAccount(); if (!is_null($this->usingAccountCallback)) { $account = call_user_func($this->usingAccountCallback, $account); @@ -151,37 +153,52 @@ public function uri(?string $endpoint = null): string $endpoint = implode('/', array_map('rawurlencode', explode('/', $endpoint))) . "?{$params}"; } - return "{$this->protocol}://{$account}.{$this->baseDomain}/{$endpoint}"; + return "{$this->protocol}://{$account}.{$this->domain}/{$endpoint}"; } /** @return array */ protected function getOptions(HttpVerb $verb, string $resource, string $body = ''): array { + $this->withVerb($verb) + ->withResource($resource) + ->withBody($body); + $options = $this->options; $headers = Headers::parse(array_merge($this->headers, [ - Resource::AUTH_DATE => $this->config->auth->getDate(), + Resource::AUTH_DATE => $this->auth->getDate(), Resource::AUTH_VERSION => Resource::VERSION, ])); if (!empty($body)) { $options['body'] = $body; - if (!$headers->has('Content-Length')) { + if (!$headers->has(Resource::CONTENT_LENGTH)) { $headers->setContentLength(strlen($body)); } } if ($this->shouldAuthenticate) { $headers = $headers->withAdditionalHeaders([ - Resource::AUTH_HEADER => $this->config->auth->getAuthentication($verb, $headers, $resource), + Resource::AUTH_HEADER => $this->auth->getAuthentication($this->withHttpHeaders($headers)), ]); - } else { - $this->withAuthentication(); } $options['headers'] = $headers->toArray(); - return $options; + return with($options, fn () => $this->resetRequestOptions()); + } + + protected function resetRequestOptions(): void + { + $this->headers = []; + $this->options = []; + $this->verb = HttpVerb::GET; + $this->httpHeaders = azure_app(Headers::class); + $this->resource = ''; + $this->body = ''; + + $this->withAuthentication(); + azure_app()->flushScoped(); } } diff --git a/src/Http/Response.php b/src/Http/Response.php index 842c843..1c853c5 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -5,10 +5,13 @@ namespace Xray\AzureStoragePhpSdk\Http; use Psr\Http\Message\ResponseInterface; +use Xray\AzureStoragePhpSdk\Concerns\HasStreamingResponse; use Xray\AzureStoragePhpSdk\Contracts\Http\Response as ResponseContract; final class Response implements ResponseContract { + use HasStreamingResponse; + public const int STATUS_OK = 200; public const int STATUS_CREATED = 201; public const int STATUS_ACCEPTED = 202; diff --git a/src/Tests/Http/Concerns/HasHttpAssertions.php b/src/Tests/Http/Concerns/HasHttpAssertions.php index bda85ff..dd594f2 100644 --- a/src/Tests/Http/Concerns/HasHttpAssertions.php +++ b/src/Tests/Http/Concerns/HasHttpAssertions.php @@ -29,7 +29,7 @@ public function assertUsingAccount(string $account): static { Assert::assertIsCallable($this->usingAccountCallback, 'Account callback not set'); - $value = call_user_func($this->usingAccountCallback, $this->getConfig()->auth->getAccount()); + $value = call_user_func($this->usingAccountCallback, $this->getAuth()->getAccount()); Assert::assertSame($account, $value); return $this; diff --git a/src/Tests/Http/Concerns/HasSharableHttp.php b/src/Tests/Http/Concerns/HasSharableHttp.php new file mode 100644 index 0000000..0612870 --- /dev/null +++ b/src/Tests/Http/Concerns/HasSharableHttp.php @@ -0,0 +1,68 @@ +verb ?? HttpVerb::GET; + } + + public function withVerb(HttpVerb $verb): static + { + $this->verb = $verb; + + return $this; + } + + public function getBody(): string + { + return $this->body ?? ''; + } + + public function withBody(string $body): static + { + $this->body = $body; + + return $this; + } + + public function getResource(): string + { + return $this->resource ?? '/'; + } + + public function withResource(string $resource): static + { + $this->resource = $resource; + + return $this; + } + + public function getHttpHeaders(): Headers + { + return $this->httpHeaders ?? azure_app(Headers::class); + } + + public function withHttpHeaders(Headers $headers): static + { + $this->httpHeaders = $headers; + + return $this; + } + +} diff --git a/src/Tests/Http/RequestFake.php b/src/Tests/Http/RequestFake.php index 58e9d4b..7efb030 100644 --- a/src/Tests/Http/RequestFake.php +++ b/src/Tests/Http/RequestFake.php @@ -5,9 +5,13 @@ namespace Xray\AzureStoragePhpSdk\Tests\Http; use Closure; +use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Config; +use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; +use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Xray\AzureStoragePhpSdk\Contracts\Http\{Request, Response}; -use Xray\AzureStoragePhpSdk\Tests\Http\Concerns\{HasAuthAssertions, HasHttpAssertions}; +use Xray\AzureStoragePhpSdk\Http\Headers; +use Xray\AzureStoragePhpSdk\Tests\Http\Concerns\{HasAuthAssertions, HasHttpAssertions, HasSharableHttp}; /** * @phpstan-type Method array{endpoint: string, body?: string} @@ -16,6 +20,11 @@ class RequestFake implements Request { use HasHttpAssertions; use HasAuthAssertions; + use HasSharableHttp; + + protected readonly Auth $auth; + + protected readonly Config $config; /** @var array */ protected array $options = []; @@ -38,15 +47,18 @@ class RequestFake implements Request protected ?ResponseFake $fakeResponse = null; - public function __construct(protected Config $config) + public function __construct(?Auth $auth = null, ?Config $config = null) { - // + $this->auth = $auth ?? azure_app(SharedKeyAuth::class, ['account' => 'account', 'key' => 'key']); + $this->config = $config ?? azure_app(Config::class); } public function withFakeResponse(ResponseFake $fakeResponse): static { $this->fakeResponse = $fakeResponse; + azure_app()->instance(Request::class, $this); + return $this; } @@ -57,6 +69,11 @@ public function usingAccount(Closure $callback): static return $this; } + public function getAuth(): Auth + { + return $this->auth; + } + public function getConfig(): Config { return $this->config; @@ -86,6 +103,7 @@ public function withOptions(array $options = []): static public function withHeaders(array $headers = []): static { $this->headers = array_merge($this->headers, $headers); + $this->withHttpHeaders(Headers::parse($this->headers)); return $this; } @@ -96,7 +114,9 @@ public function get(string $endpoint): Response 'endpoint' => $endpoint, ]; - return $this->fakeResponse ?? new ResponseFake(); + $this->withVerb(HttpVerb::GET); + + return $this->fakeResponse ?? azure_app(ResponseFake::class); } public function post(string $endpoint, string $body = ''): Response @@ -106,7 +126,10 @@ public function post(string $endpoint, string $body = ''): Response 'body' => $body, ]; - return $this->fakeResponse ?? new ResponseFake(); + $this->withVerb(HttpVerb::POST) + ->withBody($body); + + return $this->fakeResponse ?? azure_app(ResponseFake::class); } public function put(string $endpoint, string $body = ''): Response @@ -116,7 +139,10 @@ public function put(string $endpoint, string $body = ''): Response 'body' => $body, ]; - return $this->fakeResponse ?? new ResponseFake(); + $this->withVerb(HttpVerb::PUT) + ->withBody($body); + + return $this->fakeResponse ?? azure_app(ResponseFake::class); } public function delete(string $endpoint): Response @@ -125,7 +151,9 @@ public function delete(string $endpoint): Response 'endpoint' => $endpoint, ]; - return $this->fakeResponse ?? new ResponseFake(); + $this->withVerb(HttpVerb::DELETE); + + return $this->fakeResponse ?? azure_app(ResponseFake::class); } public function options(string $endpoint): Response @@ -134,12 +162,14 @@ public function options(string $endpoint): Response 'endpoint' => $endpoint, ]; - return $this->fakeResponse ?? new ResponseFake(); + $this->withVerb(HttpVerb::OPTIONS); + + return $this->fakeResponse ?? azure_app(ResponseFake::class); } public function uri(?string $endpoint = null): string { - $account = $this->config->auth->getAccount(); + $account = $this->auth->getAccount(); if (!is_null($endpoint)) { [$endpoint, $params] = array_pad(explode('?', $endpoint, 2), 2, ''); diff --git a/src/helpers.php b/src/helpers.php index 29f9926..7ebeb08 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,7 +1,73 @@ |null $key + * @param array $parameters + * @return ($key is class-string ? TClass : ($key is null ? Application : mixed)) + */ + function azure_app(?string $key = null, array $parameters = []): mixed + { + $instance = Application::getInstance(); + + if (is_null($key)) { + return $instance; + } + + return $instance->make($key, $parameters); + } +} + +if (!function_exists('with')) { + /** + * Applies a closure to a value and returns the value. + * + * @template T + * @param T $value The value to be passed to the closure. + * @param \Closure(T): void $callback The closure to be applied to the value. + * @return T The original value after the closure has been applied. + */ + function with(mixed $value, Closure $callback): mixed + { + $callback($value); + + return $value; + } +} + +if (!function_exists('is_running_in_console')) { + function is_running_in_console(): bool + { + return in_array(\PHP_SAPI, ['cli', 'phpdbg'], true); + } +} + +if (!function_exists('validate_protocol')) { + function validate_protocol(string $value): true + { + $validProtocols = ['http', 'https']; + + if (!in_array($value, $validProtocols, true)) { + throw InvalidArgumentException::create(sprintf( + 'Invalid protocol: %s. Valid protocols: %s', + $value, + implode(', ', $validProtocols), + )); + } + + return true; + } +} + if (!function_exists('str_camel_to_header')) { function str_camel_to_header(string $value): string { @@ -24,7 +90,7 @@ function convert_to_RFC1123(DateTime $dateTime): string } if (!function_exists('convert_to_RFC3339_micro')) { - function convert_to_RFC3339_micro(DateTimeImmutable $dateTime): string + function convert_to_RFC3339_micro(DateTime $dateTime): string { $utcDateTime = $dateTime->setTimezone(new DateTimeZone('UTC')); @@ -34,3 +100,16 @@ function convert_to_RFC3339_micro(DateTimeImmutable $dateTime): string return $utcDateTime->format('Y-m-d\TH:i:s.') . $microseconds . 'Z'; } } + +if (!function_exists('convert_to_ISO')) { + function convert_to_ISO(DateTimeImmutable|string $dateTime): string + { + if (is_string($dateTime)) { + $dateTime = new DateTimeImmutable($dateTime); + } + + $dateTime = $dateTime->setTimezone(new DateTimeZone('UTC')); + + return str_replace('+00:00', 'Z', $dateTime->format('c')); + } +} diff --git a/tests/Fakes/ClientFake.php b/tests/Fakes/ClientFake.php new file mode 100644 index 0000000..688d2d0 --- /dev/null +++ b/tests/Fakes/ClientFake.php @@ -0,0 +1,82 @@ +}> */ + protected array $requests = []; + + protected int $status = 200; + + /** @var array */ + protected array $headers = []; + + protected ?string $body = null; + + /** @param array $headers */ + public function withResponseFake(?string $body = null, array $headers = [], int $status = 200): self + { + $this->status = $status; + $this->headers = $headers; + $this->body = $body; + + return $this; + } + + /** @param array $options */ + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + /** @phpstan-ignore-next-line */ + return new Response($this->status, $this->headers, $this->body); + } + + /** @param array $options */ + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return new Promise(); + } + + /** @param array $options */ + public function request(string $method, mixed $uri, array $options = []): ResponseInterface + { + /** @phpstan-ignore-next-line */ + $this->requests[$method] = [ + 'uri' => $uri, + 'options' => $options, + ]; + + /** @phpstan-ignore-next-line */ + return new Response($this->status, $this->headers, $this->body); + } + + /** @param array $options */ + public function requestAsync(string $method, mixed $uri, array $options = []): PromiseInterface + { + return new Promise(); + } + + public function getConfig(?string $option = null): mixed + { + return []; + } + + public function assertRequestSent(string $method, string $uri, ?Closure $options = null): void + { + Assert::assertArrayHasKey($method, $this->requests, 'Request not sent'); + Assert::assertSame($uri, $this->requests[$method]['uri'], 'Invalid URI'); + + if (!is_null($options)) { + Assert::assertTrue($options($this->requests[$method]['options']), 'Invalid options'); + } + } +} diff --git a/tests/Feature/Application/ApplicationTest.php b/tests/Feature/Application/ApplicationTest.php new file mode 100644 index 0000000..723cbc2 --- /dev/null +++ b/tests/Feature/Application/ApplicationTest.php @@ -0,0 +1,216 @@ +group('applications'); + +afterEach(fn () => Application::getInstance()->flush()); + +it('should get the container instance as singleton', function () { + expect(Application::getInstance()) + ->toBeInstanceOf(Application::class) + ->toBe(Application::getInstance()); +}); + +it('should bind an instance to the container', function () { + $container = Application::getInstance() + ->instance('abstract', $instance = new class () {}); + + expect($container->make('abstract')) + ->toBeObject() + ->toBe($instance); +}); + +it('should bind a singleton to the container', function () { + $container = Application::getInstance() + ->singleton('testing', fn () => (new class () {})); + + expect($instance = $container->make('testing')) + ->toBeObject() + ->toBe($container->make('testing')); + + $container->singleton('testing', fn () => (new class () {})); + + expect($instance) + ->toBeObject() + ->not->toBe($container->make('testing')); +}); + +it('should bind a class to the container', function (string $abstract, ?Closure $callback = null) { + $container = Application::getInstance()->bind($abstract, $callback); + + expect($instance = $container->make($abstract)) + ->toHaveProperty('test', 'test_value'); + + expect($container->make($abstract)) + ->not->toBe($instance); +})->with([ + 'With callback' => ['abstract', fn () => (new class () { + public string $test = 'test_value'; + })], + 'Without callback' => [TestWithNoConstructor::class], +]); + +it('should throws if the provided class does not exists', function () { + Application::getInstance()->make('testing'); +})->throws(InvalidArgumentException::class, 'Cannot resolve class testing'); + +it('should scope a class to the container', function (string $abstract, ?Closure $callback = null) { + $container = Application::getInstance() + ->scope($abstract, $callback); + + expect($instance = $container->make($abstract)) + ->toHaveProperty('test', 'test_value'); + + $container->scope($abstract, $callback); + + expect($instance) + ->toHaveProperty('test', 'test_value') + ->not->toBe($container->make($abstract)); +})->with([ + 'With callback' => ['abstract', fn () => (new class () { + public string $test = 'test_value'; + })], + 'Without callback' => [TestWithNoConstructor::class], +]); + +it('should not be able to scope a class that does not exists', function () { + Application::getInstance()->scope('testing'); +})->throws(InvalidArgumentException::class, 'Cannot scope testing without a callback'); + +it('should check if there\'s a class bound to the container', function (string $method = '', bool $withValue = false, bool $resolve = false) { + $container = Application::getInstance(); + + if ($bound = ($method && $withValue)) { + $container->{$method}('key', $resolve ? new class () {} : fn () => new class () {}); + } + + expect($container->bound('key')) + ->toBe($bound); +})->with([ + 'Instance' => ['instance', true, true], + 'Singleton' => ['singleton', true], + 'Binding' => ['bind', true], + 'Scope' => ['scope', true], + 'Nothing Bound' => [], +]); + +it('should make a class with no constructor', function () { + expect(Application::getInstance()->make(TestWithNoConstructor::class)) + ->toBeInstanceOf(TestWithNoConstructor::class) + ->toHaveProperty('test', 'test_value'); +}); + +it('should make a class with constructor dependency', function () { + $container = Application::getInstance()->bind('test', fn () => 'test_value'); + + expect($instance = $container->make(TestWithConstructor::class, ['parameter' => 'test'])) + ->toBeInstanceOf(TestWithConstructor::class) + ->and($instance->dependency) + ->toBeInstanceOf(TestWithNoConstructor::class) + ->toHaveProperty('test', 'test_value'); +}); + +it('should make a class with no typed constructor when the parameter is defined', function () { + expect(Application::getInstance()->make(TestConstructorWithoutTyping::class, ['test' => 'test_value'])) + ->toBeInstanceOf(TestConstructorWithoutTyping::class) + ->toHaveProperty('test', 'test_value'); +}); + +it('should make a class when it has a default value', function () { + expect(Application::getInstance()->make(TestConstructorWithDefaultValue::class)) + ->toBeInstanceOf(TestConstructorWithDefaultValue::class) + ->toHaveProperty('test', 'test_value'); +}); + +it('should throw an exception when building a no typed class', function () { + expect(Application::getInstance()->make(TestConstructorWithoutTyping::class)) + ->toBeInstanceOf(TestConstructorWithoutTyping::class); +})->throws(InvalidArgumentException::class, 'Cannot resolve parameter $test without one defined type'); + +it('should throw an exception when binding an abstract class without a callback', function () { + Application::getInstance()->bind('abstract'); +})->throws(InvalidArgumentException::class, 'Cannot bind abstract without a callback'); + +it('should resolve a callable a method with the container', function () { + /** @var object{withConstructor: TestWithConstructor, test: string} $result */ + $result = Application::getInstance()->call(function (TestWithConstructor $withConstructor, string $test): object { + return (object)[ + 'withConstructor' => $withConstructor, + 'test' => $test, + ]; + }, ['test' => 'test_value']); + + expect($result) + ->toBeObject() + ->toHaveProperty('withConstructor') + ->toHaveProperty('test', 'test_value') + ->and($result->withConstructor) + ->toBeInstanceOf(TestWithConstructor::class); +}); + +it('should flush all resolved instances and bindings from the container', function () { + $container = Application::getInstance() + ->instance('instance', new class () {}) + ->bind('bind', fn () => true) + ->singleton('singleton', fn () => true) + ->scope('scope', fn () => true); + + $container->flush(); + + expect(false) + ->toBe($container->bound('instance')) + ->toBe($container->bound('bind')) + ->toBe($container->bound('singleton')) + ->toBe($container->bound('scope')); +}); + +it('should flush all scoped instances and bindings from the container', function () { + $container = Application::getInstance() + ->instance('instance', new class () {}) + ->bind('bind', fn () => true) + ->singleton('singleton', fn () => true) + ->scope('scope', fn () => true); + + $container->flushScoped(); + + expect(true) + ->toBe($container->bound('instance')) + ->toBe($container->bound('bind')) + ->toBe($container->bound('singleton')); + + expect(false) + ->toBe($container->bound('scope')); +}); + +class TestWithNoConstructor +{ + public string $test = 'test_value'; +} + +class TestConstructorWithoutTyping +{ + public function __construct(public $test) // @phpstan-ignore-line + { + // + } +} + +class TestConstructorWithDefaultValue +{ + public function __construct(public string $test = 'test_value') + { + // + } +} + +class TestWithConstructor +{ + public function __construct(public TestWithNoConstructor $dependency) + { + // + } +} diff --git a/tests/Feature/Authentication/MicrosoftEntraIdTest.php b/tests/Feature/Authentication/MicrosoftEntraIdTest.php new file mode 100644 index 0000000..8f0e908 --- /dev/null +++ b/tests/Feature/Authentication/MicrosoftEntraIdTest.php @@ -0,0 +1,60 @@ +group('authentications'); + +it('should implements Auth interface', function () { + expect(MicrosoftEntraId::class) + ->toImplement(Auth::class); +}); + +it('should get date formatted correctly', function () { + $auth = new MicrosoftEntraId('account', 'directory', 'application', 'secret'); + + expect($auth->getDate()) + ->toBe(gmdate('D, d M Y H:i:s T')); +}); + +it('should get the authentication account', function () { + $auth = new MicrosoftEntraId('account', 'directory', 'application', 'secret'); + + expect($auth->getAccount()) + ->toBe('account'); +}); + +it('should get correctly the authentication signature from a login request', function () { + /** @var string $body */ + $body = json_encode([ + 'token_type' => $tokeType = 'Bearer', + 'access_token' => $token = 'token', + 'expires_in' => 3600, + ]); + + $client = (new ClientFake()) + ->withResponseFake($body); + + $auth = (new MicrosoftEntraId('account', 'directory', $application = 'application', $secret = 'secret')) + ->withRequestClient($client); + + expect($auth->getAuthentication(new RequestFake())) + ->toBe("{$tokeType} {$token}"); + + expect($auth->getAuthentication(new RequestFake())) + ->toBe("{$tokeType} {$token}"); + + $client->assertRequestSent(HttpVerb::POST->value, 'https://login.microsoftonline.com/directory/oauth2/v2.0/token', fn (array $options): bool => $options === [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $application, + 'client_secret' => $secret, + 'scope' => 'https://storage.azure.com/.default', + ], + ]); +}); diff --git a/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php b/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php new file mode 100644 index 0000000..21748e3 --- /dev/null +++ b/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php @@ -0,0 +1,159 @@ +group('authentications', 'shared-access-signatures'); + +it('should implements SharedAccessSignature interface', function () { + expect(UserDelegationSas::class) + ->toImplement(SharedAccessSignature::class); +}); + +it('should throw an exception if the authentication method is not supported', function () { + $request = new RequestFake(new SharedKeyAuth('account', 'key')); + + (new UserDelegationSas($request)) + ->buildTokenUrl(AccessTokenPermission::READ, new DateTimeImmutable()); +})->throws(InvalidAuthenticationMethodException::class, sprintf('Invalid Authentication Method. [%s] needed, but [%s] given.', MicrosoftEntraId::class, SharedKeyAuth::class)); + +it('should throw an exception if the signed service is not supported', function () { + $body = << + + oid + tid + 2020-10-10T00:00:00Z + 2020-10-11T00:00:00Z + invalid + version + value + + XML; + + $request = (new RequestFake(new MicrosoftEntraId('account', 'directory', 'application', 'secret'))) + ->withFakeResponse(new ResponseFake($body)); + + (new UserDelegationSas($request)) + ->buildTokenUrl(AccessTokenPermission::READ, new DateTimeImmutable()); + + $request->assertPost('?comp=userdelegationkey&restype=service'); +})->throws(InvalidResourceTypeException::class, 'The [invalid] signed service is not valid. The allowed services are [b, c, f, s, d].'); + +it('should build the query param token correctly', function () { + $signedKeyObjectId = '050b9fa2-5df9-47b2-95d0-3342be8d943c'; + $signedKeyTenantId = '0a20a1a3-567e-45a2-8c3a-3300a41c8770'; + $signedStart = '2024-08-01T00:00:00Z'; + $signedExpiry = '2024-08-03T00:00:00Z'; + $signedService = 'b'; + $signedVersion = '2024-05-04'; + $value = 'cbUl8Ca1gwjmcFp+PTRaa5lIDJ3INnFS0suuPSCT2VA='; + + $body = << + + {$signedKeyObjectId} + {$signedKeyTenantId} + {$signedStart} + {$signedExpiry} + {$signedService} + {$signedVersion} + {$value} + + XML; + + $signedStart = new DateTimeImmutable($signedStart); + $signedExpiry = new DateTimeImmutable($signedExpiry); + $container = 'container'; + $blob = 'blob.txt'; + + $request = (new RequestFake(new MicrosoftEntraId($account = 'account', 'directory', 'application', 'secret'))) + ->withFakeResponse(new ResponseFake($body)) + ->withResource("/{$container}/{$blob}"); + + $expectedToken = createSignatureTokenForUserDelegationSasTest([ + 'account' => $account, + 'container' => $container, + 'blob' => $blob, + 'permission' => ($permission = AccessTokenPermission::READ)->value, + 'start' => $signedStart, + 'expiry' => $signedExpiry, + 'service' => $signedService, + 'version' => $signedVersion, + 'oid' => $signedKeyObjectId, + 'tid' => $signedKeyTenantId, + 'key' => $value, + ]); + + $token = (new UserDelegationSas($request)) + ->buildTokenUrl($permission, $signedExpiry); + + expect($token)->toBe($expectedToken); + + $request->assertPost('?comp=userdelegationkey&restype=service'); +}); + +/** @param array $arguments */ +function createSignatureTokenForUserDelegationSasTest(array $arguments): string +{ + /** @var string $account */ + $account = $arguments['account']; + + /** @var string $container */ + $container = $arguments['container']; + + /** @var string $blob */ + $blob = $arguments['blob']; + + $signedResource = "/blob/{$account}/{$container}/{$blob}"; + + /** @var array $parameters */ + $parameters = [ + SignatureResource::SIGNED_PERMISSION => $arguments['permission'], + SignatureResource::SIGNED_START => convert_to_ISO($arguments['start']), + SignatureResource::SIGNED_EXPIRY => convert_to_ISO($arguments['expiry']), + SignatureResource::SIGNED_CANONICAL_RESOURCE => $signedResource, + SignatureResource::SIGNED_OBJECT_ID => $arguments['oid'], + SignatureResource::SIGNED_TENANT_ID => $arguments['tid'], + SignatureResource::SIGNED_KEY_START_TIME => convert_to_ISO($arguments['start']), + SignatureResource::SIGNED_KEY_EXPIRY_TIME => convert_to_ISO($arguments['expiry']), + SignatureResource::SIGNED_KEY_SERVICE => $arguments['service'], + SignatureResource::SIGNED_KEY_VERSION => $arguments['version'], + SignatureResource::SIGNED_AUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_UNAUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_CORRELATION_ID => null, + SignatureResource::SIGNED_IP_ADDRESS => null, + SignatureResource::SIGNED_PROTOCOL => 'http', + SignatureResource::SIGNED_VERSION => $arguments['version'], + SignatureResource::SIGNED_RESOURCE => $arguments['service'], + SignatureResource::SIGNED_SNAPSHOT_TIME => null, + SignatureResource::SIGNED_ENCRYPTION_SCOPE => null, + SignatureResource::RESOURCE_CACHE_CONTROL => null, + SignatureResource::RESOURCE_CONTENT_DISPOSITION => null, + SignatureResource::RESOURCE_CONTENT_ENCODING => null, + SignatureResource::RESOURCE_CONTENT_LANGUAGE => null, + SignatureResource::RESOURCE_CONTENT_TYPE => null, + ]; + + $stringToSign = implode("\n", $parameters); + + /** @var string $key */ + $key = $arguments['key']; + + $signature = base64_encode(hash_hmac('sha256', $stringToSign, base64_decode($key), true)); + + unset($parameters[SignatureResource::SIGNED_CANONICAL_RESOURCE]); + + $queryParams = array_filter($parameters); + $queryParams[SignatureResource::SIGNATURE] = $signature; + + return http_build_query($queryParams); +} diff --git a/tests/Feature/Authentication/SharedKeyAuthTest.php b/tests/Feature/Authentication/SharedKeyAuthTest.php index 62cc7a6..217e4c2 100644 --- a/tests/Feature/Authentication/SharedKeyAuthTest.php +++ b/tests/Feature/Authentication/SharedKeyAuthTest.php @@ -4,9 +4,10 @@ use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; -use Xray\AzureStoragePhpSdk\BlobStorage\{Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Xray\AzureStoragePhpSdk\Http\Headers; +use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; uses()->group('authentications'); @@ -22,17 +23,26 @@ ->toBe(gmdate('D, d M Y H:i:s T')); }); +it('should get the authentication account', function () { + $auth = new SharedKeyAuth('account', 'key'); + + expect($auth->getAccount()) + ->toBe('account'); +}); + it('should get correctly the authentication signature for all http methods', function (HttpVerb $verb) { $decodedKey = 'my-decoded-account-key'; $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); - $headers = new Headers(); - $stringToSign = "{$verb->value}\n{$headers->toString()}\n\n/{$account}/"; + $request = (new RequestFake()) + ->withVerb($verb); + + $stringToSign = "{$request->getVerb()->value}\n{$request->getHttpHeaders()->toString()}\n\n/{$account}/"; $signature = base64_encode(hash_hmac('sha256', $stringToSign, $decodedKey, true)); - expect($auth->getAuthentication($verb, $headers, '/')) + expect($auth->getAuthentication($request)) ->toBe("SharedKey {$account}:{$signature}"); })->with([ 'GET' => [HttpVerb::GET], @@ -49,14 +59,15 @@ $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); - $verb = HttpVerb::GET; + $request = (new RequestFake()) + ->withVerb(HttpVerb::GET) + ->withHttpHeaders((new Headers())->{$headerMethod}($headerValue)); - $headers = (new Headers())->{$headerMethod}($headerValue); - $stringToSign = "{$verb->value}\n{$headers->toString()}\n\n/{$account}/"; + $stringToSign = "{$request->getVerb()->value}\n{$request->getHttpHeaders()->toString()}\n\n/{$account}/"; $signature = base64_encode(hash_hmac('sha256', $stringToSign, $decodedKey, true)); - expect($auth->getAuthentication($verb, $headers, '/')) + expect($auth->getAuthentication($request)) ->toBe("SharedKey {$account}:{$signature}"); })->with([ 'Content Encoding' => ['setContentEncoding', 'utf-8'], @@ -77,14 +88,15 @@ $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); - $verb = HttpVerb::GET; + $request = (new RequestFake()) + ->withVerb(HttpVerb::GET) + ->withHttpHeaders((new Headers())->withAdditionalHeaders([$headerMethod => $headerValue])); - $headers = (new Headers())->withAdditionalHeaders([$headerMethod => $headerValue]); - $stringToSign = "{$verb->value}\n{$headers->toString()}\n{$headers->getCanonicalHeaders()}\n/{$account}/"; + $stringToSign = "{$request->getVerb()->value}\n{$request->getHttpHeaders()->toString()}\n{$request->getHttpHeaders()->getCanonicalHeaders()}\n/{$account}/"; $signature = base64_encode(hash_hmac('sha256', $stringToSign, $decodedKey, true)); - expect($auth->getAuthentication($verb, $headers, '/')) + expect($auth->getAuthentication($request)) ->toBe("SharedKey {$account}:{$signature}"); })->with([ 'Auth Date' => [Resource::AUTH_DATE, '2024-06-10T00:00:00.000Z'], diff --git a/tests/Feature/BlobStorage/BlobStorageConfigTest.php b/tests/Feature/BlobStorage/BlobStorageConfigTest.php index c8ca661..a357231 100644 --- a/tests/Feature/BlobStorage/BlobStorageConfigTest.php +++ b/tests/Feature/BlobStorage/BlobStorageConfigTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; use Xray\AzureStoragePhpSdk\Contracts\Converter; use Xray\AzureStoragePhpSdk\Parsers\XmlParser; @@ -10,9 +9,8 @@ uses()->group('blob-storage'); it('should set default config value if none of the optional ones are provided', function () { - expect(new Config(new SharedKeyAuth('account', 'key'))) + expect(new Config()) ->version->toBe(Resource::VERSION) ->parser->toBeInstanceOf(XmlParser::class) - ->converter->toBeInstanceOf(Converter::class) - ->auth->toBeInstanceOf(SharedKeyAuth::class); + ->converter->toBeInstanceOf(Converter::class); }); diff --git a/tests/Feature/BlobStorage/BlobStorageTest.php b/tests/Feature/BlobStorage/BlobStorageTest.php index 5d85d10..7a1c8be 100644 --- a/tests/Feature/BlobStorage/BlobStorageTest.php +++ b/tests/Feature/BlobStorage/BlobStorageTest.php @@ -3,20 +3,27 @@ declare(strict_types=1); use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; +use Xray\AzureStoragePhpSdk\BlobStorage\BlobStorageClient; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobManager; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\{AccountManager, ContainerManager}; -use Xray\AzureStoragePhpSdk\BlobStorage\{BlobStorage, Config}; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; uses()->group('blob-storage'); it('should be able to get blob storage managers', function (string $method, string $class, array $parameters = []) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); - expect(new BlobStorage($request)) + expect(new BlobStorageClient($request)) ->{$method}(...$parameters)->toBeInstanceOf($class); })->with([ 'Account Manager' => ['account', AccountManager::class], 'Container Manager' => ['containers', ContainerManager::class], 'Blob Manager' => ['blobs', BlobManager::class, ['test']], ]); + +it('should create a new blob storage client', function () { + $auth = new SharedKeyAuth('account', 'key'); + + expect(BlobStorageClient::create($auth)) + ->toBeInstanceOf(BlobStorageClient::class); +}); diff --git a/tests/Feature/BlobStorage/Entities/Container/BlobStorageContainerTest.php b/tests/Feature/BlobStorage/Entities/Container/BlobStorageContainerTest.php index 45b395f..2697a4e 100644 --- a/tests/Feature/BlobStorage/Entities/Container/BlobStorageContainerTest.php +++ b/tests/Feature/BlobStorage/Entities/Container/BlobStorageContainerTest.php @@ -1,7 +1,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); $manager = new ContainerManager($request); @@ -55,7 +55,7 @@ }); it('should get the container\'s properties', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -86,7 +86,7 @@ }); it('should get the container\'s metadata', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -110,7 +110,7 @@ }); it('should delete the container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_ACCEPTED)); $manager = new ContainerManager($request); @@ -126,7 +126,7 @@ }); it('should restore the deleted container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $manager = new ContainerManager($request); @@ -142,7 +142,7 @@ }); it('should lease the container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))); + $request = (new RequestFake()); $manager = new ContainerManager($request); @@ -157,7 +157,7 @@ }); it('should get the blobs from the container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))); + $request = (new RequestFake()); $manager = new ContainerManager($request); diff --git a/tests/Feature/BlobStorage/Managers/Account/BlobStoragePreflightBlobRequestManagerTest.php b/tests/Feature/BlobStorage/Managers/Account/BlobStoragePreflightBlobRequestManagerTest.php index 4bbdbff..b2e012d 100644 --- a/tests/Feature/BlobStorage/Managers/Account/BlobStoragePreflightBlobRequestManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Account/BlobStoragePreflightBlobRequestManagerTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Account\PreflightBlobRequestManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; uses()->group('blob-storage', 'managers', 'account'); it('should send a request to the preflight blob', function (string $method, HttpVerb $verb) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); $origin = 'http://example.com'; (new PreflightBlobRequestManager($request)) diff --git a/tests/Feature/BlobStorage/Managers/Account/BlobStorageStoragePropertyManagerTest.php b/tests/Feature/BlobStorage/Managers/Account/BlobStorageStoragePropertyManagerTest.php index b997922..a692600 100644 --- a/tests/Feature/BlobStorage/Managers/Account/BlobStorageStoragePropertyManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Account/BlobStorageStoragePropertyManagerTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account\BlobStorageProperty\Cors\Cors; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account\BlobStorageProperty\{BlobProperty, DeleteRetentionPolicy, HourMetrics, Logging, MinuteMetrics, StaticWebsite}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Account\StoragePropertyManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Http\Response as BaseResponse; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; @@ -55,7 +54,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); $response = (new StoragePropertyManager($request))->get(); @@ -77,7 +76,7 @@ }); it('should save the blob property', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_NO_CONTENT)); // @phpstan-ignore-next-line diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobLeaseManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobLeaseManagerTest.php new file mode 100644 index 0000000..1d50534 --- /dev/null +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobLeaseManagerTest.php @@ -0,0 +1,137 @@ +group('blob-storage', 'managers', 'blob'); + +const BLOB_LEASE_MANAGER_REQUEST_URL = 'container/blob?comp=lease&resttype=blob'; +const BLOB_LEASE_MANAGER_VERSION = '2020-06-12'; +const BLOB_LEASE_MANAGER_LEASE_ID = '29389-29389-398439'; +const BLOB_LEASE_MANAGER_REQUEST_ID = '923-2324-2134'; + +it('should acquire a lease', function () { + ['request' => $request, 'manager' => $manager] = prepareForBlobLeaseManagerTest(); + + $response = $manager->acquire(); + + expect($response) + ->lastModified->toBeInstanceOf(DateTimeImmutable::class) + ->etag->toBe('ETAG_CODE') + ->server->toBe('MS-AZURE') + ->requestId->toBe(BLOB_LEASE_MANAGER_REQUEST_ID) + ->version->toBe(BLOB_LEASE_MANAGER_VERSION) + ->leaseId->toBe(BLOB_LEASE_MANAGER_LEASE_ID) + ->date->toBeInstanceOf(DateTimeImmutable::class); + + $request->assertPut(BLOB_LEASE_MANAGER_REQUEST_URL); +}); + +it('should renew a lease', function () { + ['request' => $request, 'blobLease' => $blobLease] = prepareForBlobLeaseManagerTest(); + + $response = $blobLease->renew(); + + expect($response) + ->lastModified->toBeInstanceOf(DateTimeImmutable::class) + ->etag->toBe('ETAG_CODE') + ->server->toBe('MS-AZURE') + ->requestId->toBe(BLOB_LEASE_MANAGER_REQUEST_ID) + ->version->toBe(BLOB_LEASE_MANAGER_VERSION) + ->leaseId->toBe(BLOB_LEASE_MANAGER_LEASE_ID) + ->date->toBeInstanceOf(DateTimeImmutable::class); + + $request->assertPut(BLOB_LEASE_MANAGER_REQUEST_URL); +}); + +it('should change a lease', function () { + ['request' => $request, 'blobLease' => $blobLease] = prepareForBlobLeaseManagerTest(); + + $response = $blobLease->change(BLOB_LEASE_MANAGER_REQUEST_ID); + + expect($response) + ->lastModified->toBeInstanceOf(DateTimeImmutable::class) + ->etag->toBe('ETAG_CODE') + ->server->toBe('MS-AZURE') + ->requestId->toBe(BLOB_LEASE_MANAGER_REQUEST_ID) + ->version->toBe(BLOB_LEASE_MANAGER_VERSION) + ->leaseId->toBe(BLOB_LEASE_MANAGER_LEASE_ID) + ->date->toBeInstanceOf(DateTimeImmutable::class); + + $request->assertPut(BLOB_LEASE_MANAGER_REQUEST_URL); +}); + +it('should release a lease', function () { + ['request' => $request, 'blobLease' => $blobLease] = prepareForBlobLeaseManagerTest(); + + $response = $blobLease->release(BLOB_LEASE_MANAGER_REQUEST_ID); + + expect($response) + ->lastModified->toBeInstanceOf(DateTimeImmutable::class) + ->etag->toBe('ETAG_CODE') + ->server->toBe('MS-AZURE') + ->requestId->toBe(BLOB_LEASE_MANAGER_REQUEST_ID) + ->version->toBe(BLOB_LEASE_MANAGER_VERSION) + ->leaseId->toBe(BLOB_LEASE_MANAGER_LEASE_ID) + ->date->toBeInstanceOf(DateTimeImmutable::class); + + $request->assertPut(BLOB_LEASE_MANAGER_REQUEST_URL); +}); + +it('should break a lease', function () { + ['request' => $request, 'blobLease' => $blobLease] = prepareForBlobLeaseManagerTest(); + + $response = $blobLease->break(BLOB_LEASE_MANAGER_REQUEST_ID); + + expect($response) + ->lastModified->toBeInstanceOf(DateTimeImmutable::class) + ->etag->toBe('ETAG_CODE') + ->server->toBe('MS-AZURE') + ->requestId->toBe(BLOB_LEASE_MANAGER_REQUEST_ID) + ->version->toBe(BLOB_LEASE_MANAGER_VERSION) + ->leaseId->toBe(BLOB_LEASE_MANAGER_LEASE_ID) + ->date->toBeInstanceOf(DateTimeImmutable::class); + + $request->assertPut(BLOB_LEASE_MANAGER_REQUEST_URL); +}); + +it('should throw an exception when trying to renew a lease without a lease id', function () { + ['blobLease' => $blobLease] = prepareForBlobLeaseManagerTest(['x-ms-lease-id' => '']); + + $blobLease->renew(); +})->throws(RequiredFieldException::class, 'Field [leaseId] is required'); + +/** + * @param array $blobLeaseHeaders + * @return array{request: RequestFake, blobLease: BlobLease, manager: BlobLeaseManager} + */ +function prepareForBlobLeaseManagerTest(array $blobLeaseHeaders = []): array +{ + $blobLeaseHeaders = array_merge([ + 'Last-Modified' => 'Wed, 15 Sep 2021 15:02:29 GMT', + 'ETag' => 'ETAG_CODE', + 'Server' => 'MS-AZURE', + 'Date' => 'Wed, 15 Sep 2021 15:02:29 GMT', + 'x-ms-request-id' => BLOB_LEASE_MANAGER_REQUEST_ID, + 'x-ms-version' => BLOB_LEASE_MANAGER_VERSION, + 'x-ms-lease-id' => BLOB_LEASE_MANAGER_LEASE_ID, + ], $blobLeaseHeaders); + + $request = (new RequestFake()) + ->withFakeResponse(new ResponseFake(headers: $blobLeaseHeaders)); + + // @phpstan-ignore-next-line + $blobLease = new BlobLease($blobLeaseHeaders); + + $manager = (new BlobLeaseManager($request, 'container', 'blob')); + + $blobLease->setManager($manager); + + return [ + 'request' => $request, + 'blobLease' => $blobLease, + 'manager' => $manager, + ]; +} diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php index 7ccfc50..26c34b1 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php @@ -1,17 +1,29 @@ group('blob-storage', 'managers', 'blobs'); it('should get the blob\'s managers', function (string $method, string $class) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect((new BlobManager($request, 'container'))->{$method}('blob')) ->toBeInstanceOf($class); // @phpstan-ignore-line @@ -22,14 +34,21 @@ ]); it('should get blob pages manager', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect((new BlobManager($request, 'container'))->pages()) ->toBeInstanceOf(BlobPageManager::class); }); +it('should get blob lease manager', function () { + $request = new RequestFake(); + + expect((new BlobManager($request, 'container'))->lease('blob')) + ->toBeInstanceOf(BlobLeaseManager::class); +}); + it('should create a new blob block', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $file = new File('name', 'content'); @@ -37,20 +56,20 @@ expect((new BlobManager($request, $container = 'container'))->putBlock($file, ['option' => 'value'])) ->toBeTrue(); - $request->assertPut("{$container}/{$file->name}?resttype=blob") + $request->assertPut("{$container}/{$file->getFilename()}?resttype=blob") ->assertSentWithOptions(['option' => 'value']) ->assertSentWithHeaders([ Resource::BLOB_TYPE => BlobType::BLOCK->value, - Resource::BLOB_CONTENT_MD5 => $file->contentMD5, - Resource::BLOB_CONTENT_TYPE => $file->contentType, - Resource::CONTENT_MD5 => $file->contentMD5, - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_LENGTH => $file->contentLength, + Resource::BLOB_CONTENT_MD5 => $file->getContentMD5(), + Resource::BLOB_CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_MD5 => $file->getContentMD5(), + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_LENGTH => $file->getContentLength(), ]); }); it('should get a blob', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body = 'blob content', headers: [ 'Content-Length' => ['10'], 'Content-Type' => ['plain/text'], @@ -72,23 +91,23 @@ expect((new BlobManager($request, $container = 'container'))->get($blob = 'blob.text', ['option' => 'value'])) ->toBeInstanceOf(File::class) - ->name->toBe($blob) - ->content->toBe($body) - ->contentLength->toBe(10) - ->contentType->toBe('plain/text') - ->contentMD5->toBe('Q2hlY2sgSW50ZWdyaXR5') - ->lastModified->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') - ->acceptRanges->toBe('bytes') - ->eTag->toBe('"0x8D8D8D8D8D8D8D9"') - ->vary->toBe('Accept-Encoding') - ->server->toBe('Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0') - ->xMsRequestId->toBe('0') - ->xMsVersion->format('Y-m-d')->toBe('2019-02-02') - ->xMsCreationTime->format('Y-m-d\TH:i:s')->toBe('2020-01-01T00:00:00') - ->xMsLeaseStatus->toBe('unlocked') - ->xMsLeaseState->toBe('available') - ->xMsBlobType->toBe('BlockBlob') - ->xMsServerEncrypted->toBe(true); + ->getFilename()->toBe($blob) + ->getContent()->toBe($body) + ->getContentLength()->toBe(10) + ->getContentType()->toBe('plain/text') + ->getContentMD5()->toBe('Q2hlY2sgSW50ZWdyaXR5') + ->getLastModified()->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') + ->getAcceptRanges()->toBe('bytes') + ->getETag()->toBe('"0x8D8D8D8D8D8D8D9"') + ->getVary()->toBe('Accept-Encoding') + ->getServer()->toBe('Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0') + ->getRequestId()->toBe('0') + ->getVersion()->toBe('2019-02-02') + ->getCreationTime()->format('Y-m-d\TH:i:s')->toBe('2020-01-01T00:00:00') + ->getLeaseStatus()->toBe('unlocked') + ->getLeaseState()->toBe('available') + ->getBlobType()->toBe('BlockBlob') + ->getServerEncrypted()->toBe(true); $request->assertGet("{$container}/{$blob}?resttype=blob") ->assertSentWithOptions(['option' => 'value']); @@ -102,7 +121,68 @@ name 2021-01-01T00:00:00.0000000Z - 2021-01-01T00:00:00.0000000Z + 2021-01-01 + true + + 2021-01-01T00:00:00.0000000Z + 10 + plain/text + Q2hlY2sgSW50ZWdyaXR5 + 0x8D8D8D8D8D8D8D9 + unlocked + available + true + + false + + + + XML; + + $request = (new RequestFake()) + ->withFakeResponse(new ResponseFake($body)); + + $result = (new BlobManager($request, $container = 'container'))->list(['option' => 'value'], includes: ['metadata', 'snapshots']); + + expect($result) + ->toBeInstanceOf(Blobs::class) + ->toHaveCount(1) + ->and($result->first()) + ->toBeInstanceOf(Blob::class) + ->name->toBe('name') + ->snapshot->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') + ->versionId->toBe('2021-01-01') + ->isCurrentVersion->toBeTrue() + ->and($result->first()?->properties) + ->toBeInstanceOf(Properties::class) + ->lastModified->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') + ->contentLength->toBe('10') + ->contentType->toBe('plain/text') + ->contentMD5->toBe('Q2hlY2sgSW50ZWdyaXR5') + ->eTag->toBe('0x8D8D8D8D8D8D8D9') + ->leaseStatus->toBe('unlocked') + ->leaseState->toBe('available') + ->serverEncrypted->toBe(true); + + $request->assertGet("{$container}/?restype=container&comp=list&include=metadata,snapshots") + ->assertSentWithOptions(['option' => 'value']); +}); + +it('should an exception if the BlobIncludeOption is invalid', function () { + $blobManager = new BlobManager(new RequestFake(), 'container'); + + $blobManager->list(includes: ['invalid']); +})->throws(InvalidArgumentException::class); + +it('should find by tag', function () { + $body = << + + + + name + 2021-01-01T00:00:00.0000000Z + 2021-01-01 true 2021-01-01T00:00:00.0000000Z @@ -120,10 +200,13 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); - $result = (new BlobManager($request, $container = 'container'))->list(['option' => 'value']); + $result = (new BlobManager($request, $container = 'container')) + ->findByTag(['option' => 'value']) + ->where('key', 'value') + ->build(); expect($result) ->toBeInstanceOf(Blobs::class) @@ -132,7 +215,7 @@ ->toBeInstanceOf(Blob::class) ->name->toBe('name') ->snapshot->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') - ->versionId->format('Y-m-d\TH:i:s')->toBe('2021-01-01T00:00:00') + ->versionId->toBe('2021-01-01') ->isCurrentVersion->toBeTrue() ->and($result->first()?->properties) ->toBeInstanceOf(Properties::class) @@ -145,6 +228,235 @@ ->leaseState->toBe('available') ->serverEncrypted->toBe(true); - $request->assertGet("{$container}/?restype=container&comp=list") + $request->assertGet("{$container}/?restype=container&comp=blobs&where=%22key%22%3D%27value%27") ->assertSentWithOptions(['option' => 'value']); }); + +it('should set expiry', function () { + $expiryTime = new DateTime('+1 hour'); + $expirationOption = ExpirationOption::ABSOLUTE; + + $request = (new RequestFake()); + + $result = (new BlobManager($request, 'container')) + ->setExpiry('test', $expirationOption, $expiryTime, ['option' => 'value']); + + $request->assertSentWithHeaders([ + Resource::EXPIRY_OPTION => $expirationOption->value, + Resource::EXPIRY_TIME => $expiryTime->format('D, d M Y H:i:s \G\M\T'), + ]) + ->assertSentWithOptions(['option' => 'value']) + ->assertPut('container/test?resttype=blob&comp=expiry'); + + expect($result) + ->toBeTrue(); +}); + +it('should set expiration as never expire', function () { + $expirationOption = ExpirationOption::NEVER_EXPIRE; + + $request = (new RequestFake()); + + $result = (new BlobManager($request, 'container')) + ->setExpiry('test', $expirationOption, options: ['option' => 'value']); + + $request->assertSentWithHeaders([ + Resource::EXPIRY_OPTION => $expirationOption->value, + ]) + ->assertSentWithOptions(['option' => 'value']) + ->assertPut('container/test?resttype=blob&comp=expiry'); + + expect($result) + ->toBeTrue(); +}); + +it('should delete a blob', function () { + $request = (new RequestFake()) + ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_ACCEPTED)); + + $snapshot = new DateTime('2021-01-01T00:00:00.0000000Z'); + + $result = (new BlobManager($request, $container = 'container'))->delete($blob = 'blob', snapshot: $snapshot, force: true); + + expect($result) + ->toBeTrue(); + + $url = "{$container}/{$blob}?" . http_build_query([ + 'resttype' => 'blob', + 'snapshot' => '2021-01-01T00:00:00.0000000Z', + 'x-ms-delete-snapshots' => 'include', + ]); + + $request->assertDelete($url); +}); + +it('should restore a blob', function () { + $request = (new RequestFake()); + + $result = (new BlobManager($request, $container = 'container')) + ->restore($blob = 'blob'); + + expect($result) + ->toBeTrue(); + + $request->assertPut("{$container}/{$blob}?comp=undelete&resttype=blob"); +}); + +it('should create a snapshot', function () { + $request = (new RequestFake()) + ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); + + $result = (new BlobManager($request, $container = 'container')) + ->createSnapshot($blob = 'blob'); + + expect($result) + ->toBeTrue(); + + $request->assertPut("{$container}/{$blob}?comp=snapshot&resttype=blob"); +}); + +it('should copy a blob', function () { + $snapshot = new DateTime('2024-07-14T15:02:29.8018334Z'); + + $request = (new RequestFake()) + ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_ACCEPTED)); + + $result = (new BlobManager($request, $container = 'container')) + ->copy($source = 'source', $destination = 'destination', ['option' => 'value'], $snapshot); + + expect($result) + ->toBeTrue(); + + $request->assertPut("{$container}/{$destination}?resttype=blob") + ->assertSentWithOptions(['option' => 'value']) + ->assertSentWithHeaders([ + Resource::COPY_SOURCE => "http://account.microsoft.azure/{$container}/{$source}?snapshot=2024-07-14T15%3A02%3A29.8018330Z", + ]); +}); + +it('should throw an exception if the expiry is before now when retrieving temporaryURL', function (int|string|DateTimeInterface $expiry) { + $request = new RequestFake(); + + (new BlobManager($request, 'container')) + ->temporaryUrl('blob', $expiry); +})->with([ + 'String' => ['2021-01-01T00:00:00.0000000Z'], + 'Integer' => [time() - 3600], + 'DateTime' => [new DateTime('yesterday')], +])->throws(InvalidArgumentException::class, 'Expiration time must be in the future'); + +it('should get a temporary URL', function () { + $expiry = new DateTimeImmutable('+1 hour'); + + $signedKeyObjectId = '050b9fa2-5df9-47b2-95d0-3342be8d943c'; + $signedKeyTenantId = '0a20a1a3-567e-45a2-8c3a-3300a41c8770'; + $signedStart = '2024-08-01T00:00:00Z'; + $signedExpiry = '2024-08-03T00:00:00Z'; + $signedService = 'b'; + $signedVersion = '2024-05-04'; + $value = 'cbUl8Ca1gwjmcFp+PTRaa5lIDJ3INnFS0suuPSCT2VA='; + + $body = << + + {$signedKeyObjectId} + {$signedKeyTenantId} + {$signedStart} + {$signedExpiry} + {$signedService} + {$signedVersion} + {$value} + + XML; + + $request = (new RequestFake(new MicrosoftEntraId('account', 'directory', 'application', 'secret'))) + ->withFakeResponse(new ResponseFake($body)); + + $container = 'container'; + $blob = 'blob.txt'; + + $uri = $request->uri("{$container}/{$blob}"); + + /** @phpstan-ignore-next-line */ + mock(UserDelegationSas::class) + ->shouldReceive('buildTokenUrl') + ->withArgs([AccessTokenPermission::READ, $expiry]) + ->andReturn($expectedResult = (http_build_query([ + 'sp' => 'r', + 'st' => '2024-08-01T00:00:00Z', + 'se' => '2024-08-03T00:00:00Z', + 'sv' => '2024-05-04', + 'srt' => 'b', + 'spr' => 'https', + 'sr' => 'b', + 'skoid' => $signedKeyObjectId, + 'sktid' => $signedKeyTenantId, + 'skt' => '2024-08-01T00:00:00Z', + 'ske' => '2024-08-03T00:00:00Z', + 'sks' => 'b', + 'skv' => '2024-05-04', + 'sig' => 'signature', + ]))); + + $result = (new BlobManager($request, $container)) + ->temporaryUrl($blob, $expiry); + + expect($result)->toBe($uri . $expectedResult); +}); + +/** @param array $arguments */ +function createSignatureTokenForBlobStorageBlobManagerTest(array $arguments): string +{ + /** @var string $account */ + $account = $arguments['account']; + + /** @var string $container */ + $container = $arguments['container']; + + /** @var string $blob */ + $blob = $arguments['blob']; + + $signedResource = "/blob/{$account}/{$container}/{$blob}"; + + /** @var array $parameters */ + $parameters = [ + SignatureResource::SIGNED_PERMISSION => $arguments['permission'], + SignatureResource::SIGNED_START => convert_to_ISO($arguments['start']), + SignatureResource::SIGNED_EXPIRY => convert_to_ISO($arguments['expiry']), + SignatureResource::SIGNED_CANONICAL_RESOURCE => $signedResource, + SignatureResource::SIGNED_OBJECT_ID => $arguments['oid'], + SignatureResource::SIGNED_TENANT_ID => $arguments['tid'], + SignatureResource::SIGNED_KEY_START_TIME => convert_to_ISO($arguments['start']), + SignatureResource::SIGNED_KEY_EXPIRY_TIME => convert_to_ISO($arguments['expiry']), + SignatureResource::SIGNED_KEY_SERVICE => $arguments['service'], + SignatureResource::SIGNED_KEY_VERSION => $arguments['version'], + SignatureResource::SIGNED_AUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_UNAUTHORIZED_OBJECT_ID => null, + SignatureResource::SIGNED_CORRELATION_ID => null, + SignatureResource::SIGNED_IP_ADDRESS => null, + SignatureResource::SIGNED_PROTOCOL => 'http', + SignatureResource::SIGNED_VERSION => $arguments['version'], + SignatureResource::SIGNED_RESOURCE => $arguments['service'], + SignatureResource::SIGNED_SNAPSHOT_TIME => null, + SignatureResource::SIGNED_ENCRYPTION_SCOPE => null, + SignatureResource::RESOURCE_CACHE_CONTROL => null, + SignatureResource::RESOURCE_CONTENT_DISPOSITION => null, + SignatureResource::RESOURCE_CONTENT_ENCODING => null, + SignatureResource::RESOURCE_CONTENT_LANGUAGE => null, + SignatureResource::RESOURCE_CONTENT_TYPE => null, + ]; + + $stringToSign = implode("\n", $parameters); + + /** @var string $key */ + $key = $arguments['key']; + + $signature = base64_encode(hash_hmac('sha256', $stringToSign, base64_decode($key), true)); + + unset($parameters[SignatureResource::SIGNED_CANONICAL_RESOURCE]); + + $queryParams = array_filter($parameters); + $queryParams[SignatureResource::SIGNATURE] = $signature; + + return http_build_query($queryParams); +} diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobMetadataManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobMetadataManagerTest.php index 6cb3e11..2d25d41 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobMetadataManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobMetadataManagerTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\BlobMetadata; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobMetadataManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; uses()->group('blob-storage', 'managers', 'blobs'); it('should get the blob\'s metadata', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Content-Length' => 1024, 'Last-Modified' => '2021-01-01T00:00:00.0000000Z', @@ -49,7 +48,7 @@ }); it('should save the blob\'s metadata', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); $blobMetadata = new BlobMetadata([ Resource::METADATA_PREFIX . 'test' => 'valid', diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPageManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPageManagerTest.php index bfd631b..c009f17 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPageManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPageManagerTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; -use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\File; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\BlobType; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\{BlobManager, BlobPageManager}; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; +use Xray\AzureStoragePhpSdk\BlobStorage\Resources\File; use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; use Xray\AzureStoragePhpSdk\Http\Response as BaseResponse; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; @@ -14,13 +13,13 @@ uses()->group('blob-storage', 'managers', 'blobs'); it('should throw an exception if the page is out of boundary', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect((new BlobPageManager($request, 'container'))->create('blob', 1025)); })->throws(InvalidArgumentException::class, 'Page blob size must be aligned to a 512-byte boundary.'); it('should create a new blob page', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $name = 'blob'; @@ -40,7 +39,7 @@ }); it('should not append a page if the page size is invalid', function (int $startPage, int $endPage, string $message) { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))); + $request = (new RequestFake()); $file = new File('name', str_repeat('a', 1536)); $options = ['foo' => 'bar']; @@ -54,7 +53,7 @@ ]); it('should append an additional page', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $file = new File('name', str_repeat('a', 1024)); @@ -65,19 +64,19 @@ expect((new BlobPageManager($request, $container = 'container'))->append($file, $startPage, $endPage, $options)) ->toBeTrue(); - $request->assertPut("{$container}/{$file->name}?resttype=blob&comp=page", $file->content) + $request->assertPut("{$container}/{$file->getFilename()}?resttype=blob&comp=page", $file->getContent()) ->assertSentWithOptions($options) ->assertSentWithHeaders([ Resource::PAGE_WRITE => 'update', Resource::RANGE => 'bytes=0-1023', - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_LENGTH => $file->contentLength, - Resource::CONTENT_MD5 => $file->contentMD5, + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_LENGTH => $file->getContentLength(), + Resource::CONTENT_MD5 => $file->getContentMD5(), ]); }); it('should put a new blob page', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $file = new File('name', str_repeat('a', 1024)); @@ -86,21 +85,21 @@ expect((new BlobPageManager($request, $container = 'container'))->put($file, $options)) ->toBeTrue(); - $request->assertPut("{$container}/{$file->name}?resttype=blob&comp=page", $file->content) + $request->assertPut("{$container}/{$file->getFilename()}?resttype=blob&comp=page", $file->getContent()) ->assertSentWithOptions($options) ->assertSentWithHeaders([ Resource::BLOB_TYPE => BlobType::PAGE->value, - Resource::BLOB_CONTENT_LENGTH => $file->contentLength, - Resource::CONTENT_TYPE => $file->contentType, - Resource::CONTENT_MD5 => $file->contentMD5, + Resource::BLOB_CONTENT_LENGTH => $file->getContentLength(), + Resource::CONTENT_TYPE => $file->getContentType(), + Resource::CONTENT_MD5 => $file->getContentMD5(), Resource::PAGE_WRITE => 'update', Resource::RANGE => 'bytes=0-1023', - Resource::CONTENT_LENGTH => $file->contentLength, + Resource::CONTENT_LENGTH => $file->getContentLength(), ]); }); it('should clear a blob page', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $name = 'blob'; @@ -120,11 +119,10 @@ }); it('should clear all the file\'s pages', function () { - $config = new Config(new SharedKeyAuth('account', 'key')); - $blobRequest = (new RequestFake($config)) + $blobRequest = (new RequestFake()) ->withFakeResponse(new ResponseFake(str_repeat('a', 1536))); - $request = (new RequestFake($config)) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); $name = 'blob'; diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPropertyManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPropertyManagerTest.php index 7de4541..29dfc16 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPropertyManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobPropertyManagerTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; -use Xray\AzureStoragePhpSdk\BlobStorage\Config; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\BlobProperty; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobPropertyManager; +use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; uses()->group('blob-storage', 'managers', 'blobs'); it('should get the blob\'s properties', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => '2021-01-01T00:00:00.0000000Z', 'ETag' => '0x8D8D8D8D8D8D8D9', @@ -36,6 +35,8 @@ 'x-ms-lease-status' => 'unlocked', ])); + azure_app()->instance(Request::class, $request); + $manager = new BlobPropertyManager($request, $container = 'container', $blob = 'blob.txt'); expect($manager->get(['option' => 'value'])) @@ -67,7 +68,7 @@ }); it('should save the blob property', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); // @phpstan-ignore-next-line $blobProperty = new BlobProperty([ diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobTagManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobTagManagerTest.php index e5b32c0..bc851be 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobTagManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobTagManagerTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Blob\BlobTag; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobTagManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Http\Response as BaseResponse; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; @@ -24,7 +23,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body, headers: [ 'Content-Length' => ['10'], 'Content-Type' => ['application/xml'], @@ -56,7 +55,7 @@ }); it('should put a new blob tag', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_NO_CONTENT)); $blobTag = new BlobTag(['key' => 'value']); diff --git a/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php b/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php index 776f3b9..6d25912 100644 --- a/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; -use Xray\AzureStoragePhpSdk\BlobStorage\Config; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Account\{AccountInformation, GeoReplication, KeyInfo, UserDelegationKey}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Account\{PreflightBlobRequestManager, StoragePropertyManager}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\AccountManager; @@ -12,7 +10,7 @@ uses()->group('blob-storage', 'managers', 'accounts'); it('should get account\'s managers', function (string $method, string $class) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect(new AccountManager($request)) ->{$method}()->toBeInstanceOf($class); @@ -22,7 +20,7 @@ ]); it('should get account information', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Server' => ['Server'], 'x-ms-request-id' => ['d5a5d3f6-0000-0000-0000-000000000000'], @@ -58,7 +56,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); expect((new AccountManager($request))->blobServiceStats(['some' => 'value'])) @@ -74,20 +72,18 @@ it('should get account user delegation key', function () { $body = << - - - oid - tid - 2020-01-01T00:00:00.0000000Z - 2020-01-02T00:00:00.0000000Z - service - version - value - - + + oid + tid + 2020-01-01T00:00:00.0000000Z + 2020-01-02T00:00:00.0000000Z + service + version + value + XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); $keyInfo = new KeyInfo([ diff --git a/tests/Feature/BlobStorage/Managers/BlobStorageContainerManagerTest.php b/tests/Feature/BlobStorage/Managers/BlobStorageContainerManagerTest.php index 63cde31..24859cc 100644 --- a/tests/Feature/BlobStorage/Managers/BlobStorageContainerManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/BlobStorageContainerManagerTest.php @@ -3,12 +3,11 @@ declare(strict_types=1); use Pest\Expectation; -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container\Properties; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container\{Container, ContainerProperties, Containers}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Container\{ContainerAccessLevelManager, ContainerLeaseManager, ContainerMetadataManager}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\ContainerManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; use Xray\AzureStoragePhpSdk\Http\Response as BaseResponse; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; @@ -16,7 +15,7 @@ uses()->group('blob-storage', 'managers', 'containers'); it('should get container\'s managers', function (string $method, string $class) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect((new ContainerManager($request))->{$method}()) ->toBeInstanceOf($class); // @phpstan-ignore-line @@ -26,7 +25,7 @@ ]); it('should get container properties', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -108,7 +107,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($xml)); $result = (new ContainerManager($request))->list(['some' => 'value'], $withDeleted); @@ -147,7 +146,7 @@ ]); it('should not be able to request when a container name is invalid', function (string $method) { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))); + $request = (new RequestFake()); $container = 'container#Name.'; @@ -161,14 +160,14 @@ ]); it('should lease a container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))); + $request = (new RequestFake()); expect((new ContainerManager($request))->lease('container')) ->toBeInstanceOf(ContainerLeaseManager::class); }); it('should create a new container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); expect((new ContainerManager($request))->create($container = 'container')) @@ -178,7 +177,7 @@ }); it('should delete an existing container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_ACCEPTED)); expect((new ContainerManager($request))->delete($container = 'container')) @@ -188,7 +187,7 @@ }); it('should restore a deleted container', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(statusCode: BaseResponse::STATUS_CREATED)); expect((new ContainerManager($request))->restore($container = 'container', $version = 'version')) diff --git a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerAccessLevelManagerTest.php b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerAccessLevelManagerTest.php index 8dfe73f..4533b88 100644 --- a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerAccessLevelManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerAccessLevelManagerTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container\AccessLevel\{ContainerAccessLevel, ContainerAccessLevels}; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Container\ContainerAccessLevelManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; uses()->group('blob-storage', 'managers', 'containers'); @@ -25,7 +24,7 @@ XML; - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake($body)); $result = (new ContainerAccessLevelManager($request)) @@ -46,7 +45,7 @@ }); it('should save the container access level', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); $accessLevel = new ContainerAccessLevel([ 'Id' => 'id', diff --git a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerLeaseManagerTest.php b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerLeaseManagerTest.php index b0e389d..80b1d1d 100644 --- a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerLeaseManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerLeaseManagerTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container\ContainerLease; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Container\ContainerLeaseManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; uses()->group('blob-storage', 'managers', 'containers'); it('should acquire a new lease', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -43,7 +42,7 @@ }); it('should renew a lease', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -73,7 +72,7 @@ }); it('should change a lease', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -104,7 +103,7 @@ }); it('should release a lease', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -134,7 +133,7 @@ }); it('should break a lease', function (?string $leaseId) { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], diff --git a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerMetadataManagerTest.php b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerMetadataManagerTest.php index 6640d57..4d58636 100644 --- a/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerMetadataManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Container/BlobStorageContainerMetadataManagerTest.php @@ -2,17 +2,16 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container\ContainerMetadata; use Xray\AzureStoragePhpSdk\BlobStorage\Managers\Container\ContainerMetadataManager; -use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; use Xray\AzureStoragePhpSdk\Tests\Http\{RequestFake, ResponseFake}; uses()->group('blob-storage', 'managers', 'containers'); it('should get the container\'s metadata', function () { - $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) + $request = (new RequestFake()) ->withFakeResponse(new ResponseFake(headers: [ 'Last-Modified' => ['2024-06-10T00:00:00.0000000Z'], 'ETag' => ['etag'], @@ -36,7 +35,7 @@ }); it('should throw an exception if the metadata key is invalid', function (string $key, string $message) { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect(fn () => (new ContainerMetadataManager($request))->save('container', [ 'valid' => 'valid', @@ -48,7 +47,7 @@ ]); it('should save the container\'s metadata', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); expect((new ContainerMetadataManager($request))->save($container = 'container', [ 'test' => 'test', diff --git a/tests/Feature/BlobStorage/Queries/BlobTagQueryTest.php b/tests/Feature/BlobStorage/Queries/BlobTagQueryTest.php new file mode 100644 index 0000000..359696f --- /dev/null +++ b/tests/Feature/BlobStorage/Queries/BlobTagQueryTest.php @@ -0,0 +1,78 @@ +group('blob-storage', 'queries'); + +it('should create a query', function () { + $request = (new RequestFake()); + + $manager = (new BlobManager($request, 'container')); + + $query = (new QueriesBlobTagQuery($manager)) + ->where('tag', 'value') + ->where('sequence', '>', '2') + ->where('sequence', '<', '10'); + + expect((fn () => $this->wheres)->call($query)) + ->toHaveCount(3) + ->toEqual([ + ['tag' => 'tag', 'operator' => '=', 'value' => 'value'], + ['tag' => 'sequence', 'operator' => '>', 'value' => '2'], + ['tag' => 'sequence', 'operator' => '<', 'value' => '10'], + ]); +}); + +it('should set the whenBuild callback', function () { + $request = (new RequestFake()); + + $manager = (new BlobManager($request, 'container')); + + $query = (new QueriesBlobTagQuery($manager)) + ->whenBuild(function (string $query): object { + return (object) ['query' => $query]; + }); + + expect((fn () => $this->callback)->call($query)) + ->toBeInstanceOf(Closure::class); +}); + +it('should throw an exception when the whenBuild callback is not set', function () { + $request = (new RequestFake()); + + $manager = (new BlobManager($request, 'container')); + + $query = (new QueriesBlobTagQuery($manager)); + + $query->build(); +})->throws(RequiredFieldException::class, 'Field [callback] is required'); + +it('should build the query', function () { + $request = (new RequestFake()); + + $manager = (new BlobManager($request, 'container')); + + $query = (new QueriesBlobTagQuery($manager)) + ->where('tag', 'value') + ->where('sequence', '>', '2') + ->where('sequence', '<', '10') + ->whenBuild(function (string $query): object { + return (object) ['query' => $query]; + }); + + expect($query->build()) + ->toEqual((object) ['query' => '%22sequence%22%3E%272%27AND%22sequence%22%3C%2710%27AND%22tag%22%3D%27value%27']); +}); + +it('should throw an exception when the operator is invalid', function () { + $request = (new RequestFake()); + + $manager = (new BlobManager($request, 'container')); + + $query = (new QueriesBlobTagQuery($manager)); + + $query->where('tag', 'invalid', 'value'); +})->throws(InvalidArgumentException::class, 'Invalid operator: invalid'); diff --git a/tests/Feature/HelpersTest.php b/tests/Feature/HelpersTest.php index 8f35920..294af96 100644 --- a/tests/Feature/HelpersTest.php +++ b/tests/Feature/HelpersTest.php @@ -1,5 +1,37 @@ group('helpers'); + +it('should check with function', function () { + $called = false; + $content = 'test'; + + expect(with($content, function (string $value) use (&$called, $content) { + $called = true; + + expect($value)->toBe($content); + }))->toBe($content); + + expect($called)->toBeTrue(); +}); + +it('should check if it\'s running in console', function () { + expect(is_running_in_console())->toBeTrue(); +}); + +it('should fail when an invalid protocol is validated', function () { + validate_protocol('invalid'); +})->throws(InvalidArgumentException::class, 'Invalid protocol: invalid. Valid protocols: http, https'); + +it('should pass when a valid protocol is validated', function (string $protocol) { + expect(validate_protocol($protocol))->toBeTrue(); +})->with([ + 'HTTP' => ['http'], + 'HTTPS' => ['https'], +]); + it('should convert camel case string to be used in the headers', function (string $value, string $expected) { expect(str_camel_to_header($value))->toBe($expected); })->with([ @@ -23,3 +55,28 @@ 'Object' => [(object)['test' => 'test'], false], 'Array' => [[1, 2, 3], false], ]); + +it('should convert date time to RFC1123 format', function () { + $datetime = (new DateTime('2022-05-26 04:12:36', new DateTimeZone('Asia/Jakarta'))); + $expected = (clone $datetime)->setTimezone(new DateTimeZone('GMT')); + + expect(convert_to_RFC1123($datetime))->toBe("{$expected->format('D, d M Y H:i:s')} GMT"); +}); + +it('should convert datetime to RFC3339 micro format', function () { + $datetime = (new DateTime('2024-08-10 12:04:59', new DateTimeZone('America/New_York'))); + $expected = (clone $datetime)->setTimezone(new DateTimeZone('UTC')); + + $microseconds = $datetime->format('u'); + $microseconds = str_pad($microseconds, 7, '0', STR_PAD_LEFT); + + expect(convert_to_RFC3339_micro($datetime))->toBe("{$expected->format('Y-m-d\TH:i:s')}.{$microseconds}Z"); +}); + +it('should convert to ISO format', function (string|DateTimeImmutable $datetime, $expected) { + expect(convert_to_ISO($datetime)) + ->toBe($expected); +})->with([ + 'String' => ['2024-10-10 12:04:59', '2024-10-10T12:04:59Z'], + 'DateTimeImmutable' => [(new DateTimeImmutable('2024-10-10 12:04:59', new DateTimeZone('UTC'))), '2024-10-10T12:04:59Z'], +]); diff --git a/tests/Feature/Http/RequestTest.php b/tests/Feature/Http/RequestTest.php index aa94446..4252962 100644 --- a/tests/Feature/Http/RequestTest.php +++ b/tests/Feature/Http/RequestTest.php @@ -2,23 +2,19 @@ declare(strict_types=1); -use GuzzleHttp\ClientInterface; -use GuzzleHttp\Promise\{Promise, PromiseInterface}; -use GuzzleHttp\Psr7\Response; -use PHPUnit\Framework\Assert; -use Psr\Http\Message\{RequestInterface, ResponseInterface}; use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Xray\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; use Xray\AzureStoragePhpSdk\Contracts\Http\Response as HttpResponse; -use Xray\AzureStoragePhpSdk\Http\Request; +use Xray\AzureStoragePhpSdk\Http\{Headers, Request}; +use Xray\Tests\Fakes\ClientFake; uses()->group('http'); it('should send get, delete, and options requests', function (string $method, HttpVerb $verb): void { - $config = new Config(new SharedKeyAuth('my_account', 'bar')); + $auth = new SharedKeyAuth('my_account', 'bar'); - $request = (new Request($config, $client = new Client())) + $request = (new Request($auth, client: $client = new ClientFake())) ->withAuthentication() ->usingAccount(fn (): string => 'foo') ->withOptions(['foo' => 'bar']); @@ -34,6 +30,15 @@ && array_key_exists(Resource::AUTH_HEADER, $options['headers']) && array_key_exists(Resource::AUTH_VERSION, $options['headers']) ); + + $getRequestOptions = fn () => (object)[ + 'options' => $this->options, + 'headers' => $this->headers, + ]; + + expect($getRequestOptions->call($request)) + ->options->toBeEmpty() + ->headers->toBeEmpty(); })->with([ 'With GET method' => ['get', HttpVerb::GET], 'With DELETE method' => ['delete', HttpVerb::DELETE], @@ -41,9 +46,9 @@ ]); it('should send post and put requests', function (string $method, HttpVerb $verb): void { - $config = new Config(new SharedKeyAuth('my_account', 'bar')); + $auth = new SharedKeyAuth('my_account', 'bar'); - $request = (new Request($config, $client = new Client())) + $request = (new Request($auth, client: $client = new ClientFake())) ->withoutAuthentication() ->withHeaders(['foo' => 'bar']); @@ -64,65 +69,74 @@ && !array_key_exists(Resource::AUTH_HEADER, $options['headers']) && array_key_exists(Resource::AUTH_VERSION, $options['headers']) ); + + $getRequestOptions = fn () => (object)[ + 'options' => $this->options, + 'headers' => $this->headers, + 'shouldAuthenticate' => $this->shouldAuthenticate, + ]; + + expect($getRequestOptions->call($request)) + ->options->toBeEmpty() + ->headers->toBeEmpty() + ->shouldAuthenticate->toBeTrue(); })->with([ 'With PUT method' => ['put', HttpVerb::PUT], 'With POST method' => ['post', HttpVerb::POST], ]); it('should get request config', function (): void { - $config = new Config(new SharedKeyAuth('my_account', 'bar')); + $auth = new SharedKeyAuth('my_account', 'bar'); + $config = new Config(); - expect((new Request($config, new Client()))->getConfig()) + expect((new Request($auth, $config, new ClientFake()))->getConfig()) ->toBe($config); }); -class Client implements ClientInterface -{ - /** @var array}> */ - protected array $requests = []; - - /** @param array $options */ - public function send(RequestInterface $request, array $options = []): ResponseInterface - { - return new Response(); - } - - /** @param array $options */ - public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface - { - return new Promise(); - } - - /** @param array $options */ - public function request(string $method, mixed $uri, array $options = []): ResponseInterface - { - /** @phpstan-ignore-next-line */ - $this->requests[$method] = [ - 'uri' => $uri, - 'options' => $options, - ]; - - return new Response(); - } - - /** @param array $options */ - public function requestAsync(string $method, mixed $uri, array $options = []): PromiseInterface - { - return new Promise(); - } - - public function getConfig(?string $option = null): mixed - { - return []; - } - - public function assertRequestSent(string $method, string $uri, ?Closure $options = null): void - { - Assert::assertArrayHasKey($method, $this->requests, 'Request not sent'); - Assert::assertSame($uri, $this->requests[$method]['uri'], 'Invalid URI'); - - if (!is_null($options)) { - Assert::assertTrue($options($this->requests[$method]['options']), 'Invalid options'); - } - } -} +it('should get request auth', function (): void { + $auth = new SharedKeyAuth('my_account', 'bar'); + + expect((new Request($auth, client: new ClientFake()))->getAuth()) + ->toBe($auth); +}); + +it('should get the http verb from request', function (HttpVerb $verb) { + $auth = new SharedKeyAuth('my_account', 'bar'); + + $request = (new Request($auth, client: new ClientFake())) + ->withVerb($verb); + + expect($request->getVerb()) + ->toBeInstanceOf(HttpVerb::class) + ->toEqual($verb); +})->with(fn () => HttpVerb::cases()); + +it('should get the resource from request', function (): void { + $auth = new SharedKeyAuth('my_account', 'bar'); + + $request = (new Request($auth, client: new ClientFake())) + ->withResource('endpoint'); + + expect($request->getResource()) + ->toEqual('endpoint'); +}); + +it('should get the headers from request', function (): void { + $auth = new SharedKeyAuth('my_account', 'bar'); + + $request = (new Request($auth, client: new ClientFake())) + ->withHttpHeaders(new Headers()); + + expect($request->getHttpHeaders()) + ->toBeInstanceOf(Headers::class); +}); + +it('should get the body from request', function (): void { + $auth = new SharedKeyAuth('my_account', 'bar'); + + $request = (new Request($auth, client: new ClientFake())) + ->withBody('body'); + + expect($request->getBody()) + ->toBe('body'); +}); diff --git a/tests/Feature/Http/ResponseTest.php b/tests/Feature/Http/ResponseTest.php index 67ace52..49419c5 100644 --- a/tests/Feature/Http/ResponseTest.php +++ b/tests/Feature/Http/ResponseTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use GuzzleHttp\Psr7\Response as GuzzleResponse; +use Xray\AzureStoragePhpSdk\BlobStorage\Resources\File; +use Xray\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; use Xray\AzureStoragePhpSdk\Http\Response; uses()->group('http'); @@ -38,3 +40,21 @@ $response = new Response(new GuzzleResponse(200, [], 'body')); expect($response->getBody())->toBe('body'); }); + +it('should validate expiration time when streaming or downloading', function (string $method) { + $file = new File('file', 'file.txt', ['Content-Type' => 'text/plain']); + + Response::{$method}($file, -1); +})->with([ + 'Streaming' => ['stream'], + 'Downloading' => ['download'], +])->throws(InvalidArgumentException::class, 'Expires cannot be less than 0.'); + +it('should stream or download the file through the response', function (string $method) { + $file = new File('file.txt', $content = 'content', ['Content-Type' => 'text/plain']); + + expect(Response::{$method}($file))->toBe($content); +})->with([ + 'Streaming' => ['stream'], + 'Downloading' => ['download'], +]); diff --git a/tests/Pest.php b/tests/Pest.php index 0ca6e81..6a1cf85 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,9 @@ namespace Xray\Tests; +use Closure; +use Mockery; + /* |-------------------------------------------------------------------------- | Test Case @@ -40,3 +43,17 @@ | global functions to help you to reduce the number of lines of code in your test files. | */ + +/** + * Mock an instance of an object in the container. + * + * @param string $abstract + * @param \Closure|null $mock + * @return \Mockery\MockInterface + */ +function mock($abstract, ?Closure $mock = null) +{ + azure_app()->instance($abstract, $instance = Mockery::mock(...array_filter(func_get_args()))); + + return $instance; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 65a84ea..e80fa37 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,15 @@ namespace Xray\Tests; use PHPUnit\Framework\TestCase as BaseTestCase; +use Xray\AzureStoragePhpSdk\Contracts\Http\Request; +use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void + { + parent::setUp(); + + azure_app()->instance(Request::class, new RequestFake()); + } } diff --git a/tests/Unit/Concerns/HasRequestSharedTest.php b/tests/Unit/Concerns/HasRequestSharedTest.php index 2d48895..0fddc64 100644 --- a/tests/Unit/Concerns/HasRequestSharedTest.php +++ b/tests/Unit/Concerns/HasRequestSharedTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use Xray\AzureStoragePhpSdk\Authentication\SharedKeyAuth; -use Xray\AzureStoragePhpSdk\BlobStorage\Config; use Xray\AzureStoragePhpSdk\Concerns\HasRequestShared; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; @@ -11,7 +9,7 @@ uses()->group('concerns', 'traits'); it('should have a request shared property', function () { - $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $request = new RequestFake(); $class = new class ($request) { /** @use HasRequestShared */ use HasRequestShared; diff --git a/tests/Unit/Entities/Blob/BlobMetadataTest.php b/tests/Unit/Entities/Blob/BlobMetadataTest.php new file mode 100644 index 0000000..bfc93de --- /dev/null +++ b/tests/Unit/Entities/Blob/BlobMetadataTest.php @@ -0,0 +1,64 @@ +group('entities', 'blobs'); + +it('should get the metadata property', function (string $property, string $value) { + $metadata = new BlobMetadata([ + 'x-ms-meta-test' => 'valid', + 'x-ms-meta-test2' => 'valid2', + ], [ + 'Content-Length' => '10', + 'Last-Modified' => 'now', + 'ETag' => 'etag', + 'Vary' => 'Accept-Encoding', + 'Server' => 'xray', + 'x-ms-request-id' => 'request-id', + 'x-ms-version' => '1.0', + 'Date' => 'now', + ]); + + expect($metadata->get($property)) + ->toBe($value); +})->with([ + 'Get Existing Property' => ['eTag', 'etag'], + 'Get Metadata Property' => ['test', 'valid'], +]); + +it('should check if the metadata property has in the metadata object', function (string $property, bool $expected) { + $metadata = new BlobMetadata([ + 'x-ms-meta-test' => 'valid', + 'x-ms-meta-test2' => 'valid2', + ], [ + 'Content-Length' => '10', + 'Last-Modified' => 'now', + 'ETag' => 'etag', + 'Vary' => 'Accept-Encoding', + ]); + + expect($metadata->has($property)) + ->toBe($expected); +})->with([ + 'Check Option Property Exists' => ['eTag', true], + 'Check Option Property Missing' => ['server', false], + 'Check Metadata Property Exists' => ['test', true], + 'Check Metadata Property Missing' => ['test3', false], +]); + +it('should get metadata properties to save', function () { + $metadata = new BlobMetadata([ + 'x-ms-meta-test' => 'valid', + 'test2' => 'valid2', + 'x-ms-meta-test3' => null, + 'test4' => null, + ]); + + expect($metadata->getMetadataToSave()) + ->toEqual([ + 'x-ms-meta-test' => 'valid', + 'x-ms-meta-test2' => 'valid2', + ]); +}); diff --git a/tests/Unit/Entities/Blob/BlobTagTest.php b/tests/Unit/Entities/Blob/BlobTagTest.php new file mode 100644 index 0000000..fdc11ed --- /dev/null +++ b/tests/Unit/Entities/Blob/BlobTagTest.php @@ -0,0 +1,107 @@ +group('entities', 'blobs'); + +it('should mount the blob tags', function () { + $blobTag = new BlobTag([ + ['Key' => 'key', 'Value' => 'value'], + 'key2' => 'value2', + ]); + + expect($blobTag) + ->tags->toEqual([ + 'key' => 'value', + 'key2' => 'value2', + ]); +}); + +it('should throw an exception if the tag structure is invalid', function (array $tag) { + $blobTag = new BlobTag([...$tag]); + + expect($blobTag) + ->toBeInstanceOf(BlobTag::class); +})->with([ + 'Invalid Key Value Pair' => [[['value' => 'key']]], + 'Invalid Array Tag' => [['key' => ['value']]], +])->throws(InvalidArgumentException::class, 'Invalid tag structure'); + +it('should throw an exception if the tag key has more than 128 characters', function () { + $key = str_repeat('a', 129); + + expect(fn () => new BlobTag([$key => 'value'])) + ->toThrow(InvalidArgumentException::class, "Invalid tag key: {$key}. Tag keys cannot be more than 128 characters in length."); +}); + +it('should throw an exception if the tag key is not alphanumeric and some special characters', function () { + $key = '#test key'; + + expect(fn () => new BlobTag([$key => 'value'])) + ->toThrow(InvalidArgumentException::class, "Invalid tag key: {$key}. Only alphanumeric characters and '+ . / : = _' are allowed."); +}); + +it('should throw an exception if the tag value has more than 256 characters', function () { + $value = str_repeat('a', 257); + + expect(fn () => new BlobTag(['key' => $value])) + ->toThrow(InvalidArgumentException::class, "Invalid tag value: {$value}. Tag values cannot be more than 256 characters in length."); +}); + +it('should throw an exception if the tag value is not alphanumeric and some special characters', function () { + $value = '#test value'; + + expect(fn () => new BlobTag(['key' => $value])) + ->toThrow(InvalidArgumentException::class, "Invalid tag value: {$value}. Only alphanumeric characters and '+ . / : = _' are allowed."); +}); + +it('should find a tag property', function (string $property, int|string|null $value) { + $blobTag = new BlobTag([ + ['Key' => 'key', 'Value' => 'value'], + 'key2' => 'value2', + ], ['Content-Length' => '10']); + + expect($blobTag->find($property))->toBe($value); +})->with([ + 'Get Existing Property' => ['contentLength', 10], + 'Get Non-Existing Property' => ['server', null], + 'Get Existing Tag' => ['key', 'value'], + 'Get Non-Existing Tag' => ['key3', null], +]); + +it('should check if a tag property exists', function (string $property, bool $exists) { + $blobTag = new BlobTag([ + ['Key' => 'key', 'Value' => 'value'], + 'key2' => 'value2', + ], ['Content-Length' => '10']); + + expect($blobTag->has($property))->toBe($exists); +})->with([ + 'Check Existing Property' => ['contentLength', true], + 'Check Non-Existing Property' => ['server', false], + 'Check Existing Tag' => ['key', true], + 'Check Non-Existing Tag' => ['key3', false], +]); + +it('should convert the blob tag to xml', function () { + $blobTag = new BlobTag([ + ['Key' => 'key', 'Value' => 'value'], + 'key2' => 'value2', + ]); + + $xml = << + + + keyvalue + key2value2 + + + XML; + + expect(preg_replace('/\s/m', '', $blobTag->toXml())) + ->toBe(preg_replace('/\s/m', '', $xml)); +}); diff --git a/tests/Unit/Entities/Blob/BlobTest.php b/tests/Unit/Entities/Blob/BlobTest.php new file mode 100644 index 0000000..46180cc --- /dev/null +++ b/tests/Unit/Entities/Blob/BlobTest.php @@ -0,0 +1,209 @@ +group('entities', 'blobs'); + +it('should throw an exception if the blob name isn\'t provided', function () { + $blob = new Blob([]); + + expect($blob)->toBeInstanceOf(Blob::class); +})->throws(RequiredFieldException::class, 'Field [Name] is required'); + +it('should get the file from the blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('get') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($name, $options = ['foo' => 'bar']) + ->andReturn($file = new File('filename', 'content')); + + expect($blob->get($options)) + ->toBeInstanceOf(File::class) + ->toBe($file); +}); + +it('should get the blob\'s properties', function () { + $propertyMock = mock(BlobPropertyManager::class) // @phpstan-ignore-line + ->shouldReceive('get') + ->atLeast() + ->once() + ->with($options = ['foo' => 'bar']) + ->andReturn($blobProperty = azure_app(BlobProperty::class, ['property' => []])) + ->getMock(); + + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class) // @phpstan-ignore-line + ->shouldReceive('properties') + ->atLeast() + ->once() + ->with($name = 'name') + ->andReturn($propertyMock) + ->getMock(); + + $blob = (new Blob([ + 'Name' => $name, + 'Version' => 'version', + ]))->setManager($mock); + + expect($blob->getProperties($options)) + ->toBeInstanceOf(BlobProperty::class) + ->toBe($blobProperty); +}); + +it('should delete the blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + 'Snapshot' => $snapshot = '2024-01-01T00:00:00Z', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('delete') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($name, $snapshot, $force = false) + ->andReturnTrue(); + + expect($blob->delete($force)) + ->toBeTrue(); +}); + +it('should copy a blob to a new name', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + 'Snapshot' => $snapshot = '2024-01-01T00:00:00Z', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('copy') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($name, $destination = 'destination', $options = ['foo' => 'bar'], $snapshot) + ->andReturnTrue(); + + expect($blob->copy($destination, $options)) + ->toBeTrue(); +}); + +it('should restore a deleted blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('restore') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($name) + ->andReturnTrue(); + + expect($blob->restore()) + ->toBeTrue(); +}); + +it('should create a snapshot of the blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('createSnapshot') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($name) + ->andReturnTrue(); + + expect($blob->createSnapshot()) + ->toBeTrue(); +}); + +it('should get tags from the blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class) // @phpstan-ignore-line + ->shouldReceive('tags') + ->with($name = 'name') + ->andReturn(azure_app(BlobTagManager::class, ['containerName' => 'container', 'blobName' => $name])) + ->getMock(); + + $blob = (new Blob([ + 'Name' => $name, + 'Version' => 'version', + ]))->setManager($mock); + + expect($blob->tags()) + ->toBeInstanceOf(BlobTagManager::class); +}); + +it('should lease a blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class) // @phpstan-ignore-line + ->shouldReceive('lease') + ->with($name = 'name') + ->andReturn(azure_app(BlobLeaseManager::class, ['containerName' => 'container', 'blobName' => $name])) + ->getMock(); + + $blob = (new Blob([ + 'Name' => $name, + 'Version' => 'version', + ]))->setManager($mock); + + expect($blob->lease()) + ->toBeInstanceOf(BlobLeaseManager::class); +}); + +it('should set the expiry of the blob', function () { + /** @var BlobManager $mock */ + $mock = mock(BlobManager::class); + + $blob = (new Blob([ + 'Name' => $name = 'name', + 'Version' => 'version', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('setExpiry') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with( + $name, + $option = ExpirationOption::NEVER_EXPIRE, + $expiry = new DateTime('2024-01-01T00:00:00Z'), + $options = ['foo' => 'bar'], + )->andReturnTrue(); + + expect($blob->setExpiry($option, $expiry, $options)) + ->toBeTrue(); +}); diff --git a/tests/Unit/Entities/Container/ContainerLeaseTest.php b/tests/Unit/Entities/Container/ContainerLeaseTest.php new file mode 100644 index 0000000..e32fe7a --- /dev/null +++ b/tests/Unit/Entities/Container/ContainerLeaseTest.php @@ -0,0 +1,78 @@ +group('entities', 'containers'); + +it('should renew the container lease', function () { + /** @var ContainerLeaseManager $mock */ + $mock = mock(ContainerLeaseManager::class); + + $containerLease = (new ContainerLease([ + 'Last-Modified' => '2024-06-10T00:00:00.0000000Z', + 'ETag' => 'etag', + 'Server' => 'server', + 'Version' => 'version', + 'Date' => '2024-06-10T00:00:00.0000000Z', + 'x-ms-lease-id' => $leaseId = 'leaseId', + ]))->setManager($mock); + + /** @var MockInterface $mock */ + $mock->shouldReceive('renew') // @phpstan-ignore-line + ->atLeast() + ->once() + ->with($leaseId) + ->andReturn($containerLease); + + expect($containerLease->renew()) + ->toBeInstanceOf(ContainerLease::class); +}); + +it('should change/release/break the container lease', function (string $method, ?string $toLeaseId = null) { + /** @var ContainerLeaseManager $mock */ + $mock = mock(ContainerLeaseManager::class); + + $containerLease = (new ContainerLease([ + 'Last-Modified' => '2024-06-10T00:00:00.0000000Z', + 'ETag' => 'etag', + 'Server' => 'server', + 'Version' => 'version', + 'Date' => '2024-06-10T00:00:00.0000000Z', + 'x-ms-lease-id' => $fromLeaseId = 'leaseId', + ]))->setManager($mock); + + $params = array_filter([$fromLeaseId, $toLeaseId]); + + /** @var MockInterface $mock */ + $mock->shouldReceive($method) // @phpstan-ignore-line + ->atLeast() + ->once() + ->with(...$params) + ->andReturn($containerLease); + + expect($containerLease->{$method}(count($params) === 2 ? $toLeaseId : $fromLeaseId)) + ->toBeInstanceOf(ContainerLease::class); +})->with([ + 'Change' => ['change', 'toLeaseId'], + 'Release' => ['release'], + 'Break' => ['break'], +]); + +it('should ensure the lease id is set', function () { + $containerLease = new ContainerLease([ + 'Last-Modified' => '2024-06-10T00:00:00.0000000Z', + 'ETag' => 'etag', + 'Server' => 'server', + 'Version' => 'version', + 'Date' => '2024-06-10T00:00:00.0000000Z', + ]); + + expect($containerLease->renew())->toBeInstanceOf(ContainerLease::class); +})->throws(RequiredFieldException::class, 'Field [leaseId] is required'); diff --git a/tests/Unit/Resources/FileTest.php b/tests/Unit/Resources/FileTest.php new file mode 100644 index 0000000..8011924 --- /dev/null +++ b/tests/Unit/Resources/FileTest.php @@ -0,0 +1,61 @@ +group('resources'); + +it('should not be able to create a file without a name', function () { + $file = new File('', 'content'); + + expect($file)->toBeInstanceOf(File::class); +})->throws(InvalidArgumentException::class, '[name] cannot be empty'); + +it('should get file information', function (string $method, string|bool|int|DateTimeImmutable $expected) { + /** @phpstan-ignore-next-line */ + $file = new File('name', 'content', [ + 'Content-Type' => 'text/plain', + 'Content-Length' => 7, + 'Content-MD5' => 'md5', + 'Last-Modified' => '2021-10-02T00:00:00.0000000Z', + 'Accept-Ranges' => 'bytes', + 'ETag' => 'etag', + 'Vary' => 'Accept-Encoding', + 'Server' => 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id' => 'request-id', + 'x-ms-version' => '2019-02-02', + 'x-ms-creation-time' => '2021-01-01T00:00:00.0000000Z', + 'x-ms-lease-status' => 'unlocked', + 'x-ms-lease-state' => 'available', + 'x-ms-blob-type' => 'BlockBlob', + 'x-ms-server-encrypted' => 'true', + 'Date' => '2021-10-05T00:00:00.0000000Z', + ]); + + $formatValue = fn (string|bool|int|DateTimeImmutable $value) => $value instanceof DateTimeImmutable + ? $value->format('Y-m-d') + : $value; + + expect($formatValue($file->{$method}()))->toBe($formatValue($expected)); +})->with([ + 'Filename' => ['getFilename', 'name'], + 'Content' => ['getContent', 'content'], + 'Content Length' => ['getContentLength', 7], + 'Content Type' => ['getContentType', 'text/plain'], + 'Content MD5' => ['getContentMd5', 'md5'], + 'Last Modified' => ['getLastModified', fn () => new DateTimeImmutable('2021-10-02T00:00:00.0000000Z')], + 'Accept Ranges' => ['getAcceptRanges', 'bytes'], + 'ETag' => ['getETag', 'etag'], + 'Vary' => ['getVary', 'Accept-Encoding'], + 'Server' => ['getServer', 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0'], + 'Request' => ['getRequestId', 'request-id'], + 'Version' => ['getVersion', '2019-02-02'], + 'Created' => ['getCreationTime', fn () => new DateTimeImmutable('2021-01-01T00:00:00.0000000Z')], + 'Lease' => ['getLeaseStatus', 'unlocked'], + 'State' => ['getLeaseState', 'available'], + 'Type' => ['getBlobType', 'BlockBlob'], + 'Encrypted' => ['getServerEncrypted', true], + 'Date' => ['getDate', fn () => new DateTimeImmutable('2021-10-05T00:00:00.0000000Z')], +]);