diff --git a/composer.json b/composer.json index 465c3fe..47307f0 100644 --- a/composer.json +++ b/composer.json @@ -39,13 +39,17 @@ ], "autoload": { "psr-4": { - "Xray\\AzureStoragePhpSdk\\": "src/", - "Xray\\Tests\\": "tests/" + "Xray\\AzureStoragePhpSdk\\": "src/" }, "files": [ "src/helpers.php" ] }, + "autoload-dev": { + "psr-4": { + "Xray\\Tests\\": "tests/" + } + }, "minimum-stability": "stable", "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 2656f77..d06fdea 100644 --- a/composer.lock +++ b/composer.lock @@ -700,16 +700,16 @@ }, { "name": "captainhook/captainhook", - "version": "5.23.3", + "version": "5.23.4", "source": { "type": "git", "url": "https://github.com/captainhookphp/captainhook.git", - "reference": "c9deaefc098dde7f7093b44482b099195442e70d" + "reference": "53cdd84c14dd50d229cf5ebef66b20a128ade613" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/captainhookphp/captainhook/zipball/c9deaefc098dde7f7093b44482b099195442e70d", - "reference": "c9deaefc098dde7f7093b44482b099195442e70d", + "url": "https://api.github.com/repos/captainhookphp/captainhook/zipball/53cdd84c14dd50d229cf5ebef66b20a128ade613", + "reference": "53cdd84c14dd50d229cf5ebef66b20a128ade613", "shasum": "" }, "require": { @@ -772,7 +772,7 @@ ], "support": { "issues": "https://github.com/captainhookphp/captainhook/issues", - "source": "https://github.com/captainhookphp/captainhook/tree/5.23.3" + "source": "https://github.com/captainhookphp/captainhook/tree/5.23.4" }, "funding": [ { @@ -780,7 +780,7 @@ "type": "github" } ], - "time": "2024-07-07T19:12:59+00:00" + "time": "2024-08-22T07:50:22+00:00" }, { "name": "captainhook/hook-installer", @@ -887,26 +887,26 @@ }, { "name": "composer/pcre", - "version": "3.2.0", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90" + "reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/ea4ab6f9580a4fd221e0418f2c357cdd39102a90", - "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90", + "url": "https://api.github.com/repos/composer/pcre/zipball/1637e067347a0c40bbb1e3cd786b20dcab556a81", + "reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.8" + "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan": "^1.11.10", "phpstan/phpstan-strict-rules": "^1.1", "phpunit/phpunit": "^8 || ^9" }, @@ -946,7 +946,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.2.0" + "source": "https://github.com/composer/pcre/tree/3.3.0" }, "funding": [ { @@ -962,7 +962,7 @@ "type": "tidelift" } ], - "time": "2024-07-25T09:36:02+00:00" + "time": "2024-08-19T19:43:53+00:00" }, { "name": "composer/xdebug-handler", @@ -1836,21 +1836,21 @@ }, { "name": "pestphp/pest", - "version": "v2.35.0", + "version": "v2.35.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646" + "reference": "b13acb630df52c06123588d321823c31fc685545" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646", - "reference": "d0ff2c8ec294b7aa7fcb0f3ddc4fdec864234646", + "url": "https://api.github.com/repos/pestphp/pest/zipball/b13acb630df52c06123588d321823c31fc685545", + "reference": "b13acb630df52c06123588d321823c31fc685545", "shasum": "" }, "require": { "brianium/paratest": "^7.3.1", - "nunomaduro/collision": "^7.10.0|^8.3.0", + "nunomaduro/collision": "^7.10.0|^8.4.0", "nunomaduro/termwind": "^1.15.1|^2.0.1", "pestphp/pest-plugin": "^2.1.1", "pestphp/pest-plugin-arch": "^2.7.0", @@ -1928,7 +1928,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v2.35.0" + "source": "https://github.com/pestphp/pest/tree/v2.35.1" }, "funding": [ { @@ -1940,7 +1940,7 @@ "type": "github" } ], - "time": "2024-08-02T10:57:29+00:00" + "time": "2024-08-20T21:41:50+00:00" }, { "name": "pestphp/pest-plugin", @@ -2508,16 +2508,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.11.10", + "version": "1.11.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "640410b32995914bde3eed26fa89552f9c2c082f" + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/640410b32995914bde3eed26fa89552f9c2c082f", - "reference": "640410b32995914bde3eed26fa89552f9c2c082f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3", "shasum": "" }, "require": { @@ -2562,36 +2562,36 @@ "type": "github" } ], - "time": "2024-08-08T09:02:50+00:00" + "time": "2024-08-19T14:37:29+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.15", + "version": "10.1.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae" + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.18 || ^5.0", + "nikic/php-parser": "^4.19.1 || ^5.1.0", "php": ">=8.1", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-text-template": "^3.0", - "sebastian/code-unit-reverse-lookup": "^3.0", - "sebastian/complexity": "^3.0", - "sebastian/environment": "^6.0", - "sebastian/lines-of-code": "^2.0", - "sebastian/version": "^4.0", - "theseer/tokenizer": "^1.2.0" + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { "phpunit/phpunit": "^10.1" @@ -2603,7 +2603,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1-dev" + "dev-main": "10.1.x-dev" } }, "autoload": { @@ -2632,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.15" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" }, "funding": [ { @@ -2640,7 +2640,7 @@ "type": "github" } ], - "time": "2024-06-29T08:25:15+00:00" + "time": "2024-08-22T04:31:57+00:00" }, { "name": "phpunit/php-file-iterator", @@ -3041,16 +3041,16 @@ }, { "name": "psr/log", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + "reference": "79dff0b268932c640297f5208d6298f71855c03e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", + "reference": "79dff0b268932c640297f5208d6298f71855c03e", "shasum": "" }, "require": { @@ -3085,9 +3085,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.1" }, - "time": "2021-07-14T16:46:02+00:00" + "time": "2024-08-21T13:31:24+00:00" }, { "name": "sebastian/cli-parser", @@ -3259,16 +3259,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372" + "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", + "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", "shasum": "" }, "require": { @@ -3279,7 +3279,7 @@ "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.3" + "phpunit/phpunit": "^10.4" }, "type": "library", "extra": { @@ -3324,7 +3324,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.2" }, "funding": [ { @@ -3332,7 +3332,7 @@ "type": "github" } ], - "time": "2023-08-14T13:18:12+00:00" + "time": "2024-08-12T06:03:08+00:00" }, { "name": "sebastian/complexity", diff --git a/src/Authentication/MicrosoftEntraId.php b/src/Authentication/MicrosoftEntraId.php index c2bdd6a..3eae9eb 100644 --- a/src/Authentication/MicrosoftEntraId.php +++ b/src/Authentication/MicrosoftEntraId.php @@ -11,25 +11,40 @@ 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\Exceptions\{RequestException, RequiredFieldException}; final class MicrosoftEntraId implements Auth { use UseCurrentHttpDate; + protected string $account; + + protected string $directoryId; + + protected string $applicationId; + + protected string $applicationSecret; + protected ?ClientInterface $client = null; protected string $token = ''; protected ?DateTime $tokenExpiresAt = null; - public function __construct( - protected string $account, - protected string $directoryId, - protected string $applicationId, - protected string $applicationSecret, - ) { - // + /** @param array{account: string, directory: string, application: string, secret: string} $config */ + public function __construct(array $config) + { + // @phpstan-ignore-next-line + if (!isset($config['account'], $config['directory'], $config['application'], $config['secret'])) { + $missingParameters = array_diff(['account', 'directory', 'application', 'secret'], array_keys($config)); + + throw RequiredFieldException::create('Missing required parameters: ' . implode(', ', $missingParameters)); + } + + $this->account = $config['account']; + $this->directoryId = $config['directory']; + $this->applicationId = $config['application']; + $this->applicationSecret = $config['secret']; } public function withRequestClient(ClientInterface $client): self diff --git a/src/Authentication/SharedKeyAuth.php b/src/Authentication/SharedKeyAuth.php index 8f6a488..85f18d1 100644 --- a/src/Authentication/SharedKeyAuth.php +++ b/src/Authentication/SharedKeyAuth.php @@ -7,14 +7,28 @@ use Xray\AzureStoragePhpSdk\Concerns\UseCurrentHttpDate; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Xray\AzureStoragePhpSdk\Contracts\Http\Request; +use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; final class SharedKeyAuth implements Auth { use UseCurrentHttpDate; - public function __construct(protected string $account, protected string $key) + protected string $account; + + protected string $key; + + /** @param array{account: string, key: string} $config */ + public function __construct(array $config) { - // + // @phpstan-ignore-next-line + if (!isset($config['account'], $config['key'])) { + $missingParameters = array_diff(['account', 'key'], array_keys($config)); + + throw RequiredFieldException::create('Missing required parameters: ' . implode(', ', $missingParameters)); + } + + $this->account = $config['account']; + $this->key = $config['key']; } public function getAccount(): string diff --git a/src/BlobStorage/BlobStorageClient.php b/src/BlobStorage/BlobStorageClient.php index 432479e..a731688 100644 --- a/src/BlobStorage/BlobStorageClient.php +++ b/src/BlobStorage/BlobStorageClient.php @@ -25,6 +25,16 @@ public static function create(Auth $auth, array $config = []): static return new static(new Request($auth, new Config($config))); } + public function getRequest(): RequestContract + { + return $this->request; + } + + public function getConfig(): Config + { + return $this->request->getConfig(); + } + public function account(): AccountManager { return azure_app(AccountManager::class); diff --git a/src/BlobStorage/Entities/Container/ContainerProperties.php b/src/BlobStorage/Entities/Container/ContainerProperties.php index 3813163..adce3d2 100644 --- a/src/BlobStorage/Entities/Container/ContainerProperties.php +++ b/src/BlobStorage/Entities/Container/ContainerProperties.php @@ -5,6 +5,7 @@ namespace Xray\AzureStoragePhpSdk\BlobStorage\Entities\Container; use DateTimeImmutable; +use Xray\AzureStoragePhpSdk\BlobStorage\Resource; final readonly class ContainerProperties { @@ -32,6 +33,8 @@ public bool $xMsDenyEncryptionScopeOverride; + public ?string $blobPublicAccess; + public DateTimeImmutable $date; /** @param array $containerProperty */ @@ -40,15 +43,16 @@ public function __construct(array $containerProperty) $this->lastModified = new DateTimeImmutable($containerProperty['Last-Modified'] ?? 'now'); $this->eTag = $containerProperty['ETag'] ?? ''; $this->server = $containerProperty['Server'] ?? ''; - $this->xMsRequestId = $containerProperty['x-ms-request-id'] ?? ''; - $this->xMsVersion = $containerProperty['x-ms-version'] ?? ''; - $this->xMsLeaseStatus = $containerProperty['x-ms-lease-status'] ?? ''; - $this->xMsLeaseState = $containerProperty['x-ms-lease-state'] ?? ''; + $this->xMsRequestId = $containerProperty[Resource::REQUEST_ID] ?? ''; + $this->xMsVersion = $containerProperty[Resource::AUTH_VERSION] ?? ''; + $this->xMsLeaseStatus = $containerProperty[Resource::LEASE_STATUS] ?? ''; + $this->xMsLeaseState = $containerProperty[Resource::LEASE_STATE] ?? ''; $this->xMsHasImmutabilityPolicy = to_boolean($containerProperty['x-ms-has-immutability-policy'] ?? ''); $this->xMsHasLegalHold = to_boolean($containerProperty['x-ms-has-legal-hold'] ?? ''); $this->xMsImmutableStorageWithVersioningEnabled = to_boolean($containerProperty['x-ms-immutable-storage-with-versioning-enabled'] ?? ''); $this->xMsDefaultEncryptionScopeOverride = $containerProperty['x-ms-default-encryption-scope'] ?? ''; $this->xMsDenyEncryptionScopeOverride = to_boolean($containerProperty['x-ms-deny-encryption-scope-override'] ?? ''); $this->date = new DateTimeImmutable($containerProperty['Date'] ?? 'now'); + $this->blobPublicAccess = $containerProperty[Resource::BLOB_PUBLIC_ACCESS] ?? null; } } diff --git a/src/BlobStorage/Resource.php b/src/BlobStorage/Resource.php index c9015ea..186a806 100644 --- a/src/BlobStorage/Resource.php +++ b/src/BlobStorage/Resource.php @@ -21,11 +21,14 @@ final class Resource public const string CONTENT_LENGTH = 'Content-Length'; public const string REQUEST_ID = 'x-ms-request-id'; - public const string LEASE_ID = 'x-ms-lease-id'; + public const string LEASE_ID = 'x-ms-lease-id'; + public const string LEASE_ACTION = 'x-ms-lease-action'; public const string LEASE_BREAK_PERIOD = 'x-ms-lease-break-period'; public const string LEASE_DURATION = 'x-ms-lease-duration'; public const string LEASE_PROPOSED_ID = 'x-ms-proposed-lease-id'; + public const string LEASE_STATUS = 'x-ms-lease-status'; + public const string LEASE_STATE = 'x-ms-lease-state'; public const string DELETE_CONTAINER_NAME = 'x-ms-deleted-container-name'; public const string DELETE_CONTAINER_VERSION = 'x-ms-deleted-container-version'; @@ -38,6 +41,7 @@ final class Resource public const string PAGE_WRITE = 'x-ms-page-write'; public const string RANGE = 'x-ms-range'; + public const string BLOB_PUBLIC_ACCESS = 'x-ms-blob-public-access'; public const string BLOB_CACHE_CONTROL = 'x-ms-blob-cache-control'; public const string BLOB_CONTENT_TYPE = 'x-ms-blob-content-type'; public const string BLOB_CONTENT_MD5 = 'x-ms-blob-content-md5'; diff --git a/src/Exceptions/RequiredFieldException.php b/src/Exceptions/RequiredFieldException.php index 2c3f737..5333f3f 100644 --- a/src/Exceptions/RequiredFieldException.php +++ b/src/Exceptions/RequiredFieldException.php @@ -8,9 +8,9 @@ final class RequiredFieldException extends Exception { - protected function __construct(string $message) + public static function create(string $message): static { - parent::__construct($message); + return new static($message); } public static function missingField(string $field): static diff --git a/src/Support/Collection.php b/src/Support/Collection.php index 37b356a..57b9b8c 100644 --- a/src/Support/Collection.php +++ b/src/Support/Collection.php @@ -4,19 +4,23 @@ namespace Xray\AzureStoragePhpSdk\Support; +use ArgumentCountError; use ArrayAccess; use ArrayIterator; use IteratorAggregate; use JsonSerializable; use Traversable; +use Xray\AzureStoragePhpSdk\Contracts\Arrayable; /** - * @template TKey of array-key - * @template TValue of object + * @template TKey + * @template TValue + * + * @implements Arrayable>> * @implements IteratorAggregate * @implements ArrayAccess */ -class Collection implements IteratorAggregate, ArrayAccess, JsonSerializable +class Collection implements Arrayable, IteratorAggregate, ArrayAccess, JsonSerializable { /** @param array $items */ public function __construct(protected array $items = []) @@ -30,43 +34,311 @@ public function all(): array return $this->items; } - /** @return TValue|null */ - public function first(): ?object + /** + * Get the first item from the collection passing the given truth test. + * + * @template TFirstDefault + * + * @param (callable(TValue, TKey): bool)|null $callback + * @param TFirstDefault|(\Closure(): TFirstDefault) $default + * @return TValue|TFirstDefault|null + */ + public function first(?callable $callback = null, mixed $default = null): mixed { - return $this->get(0); + /** @var TFirstDefault $default */ + $default = is_callable($default) + ? call_user_func($default) + : $default; + + if (is_null($callback)) { + if ($this->isEmpty()) { + return $default; + } + + foreach ($this as $item) { + return $item; + } + + return $default; // @codeCoverageIgnore + } + + foreach ($this as $key => $value) { + if ($callback($value, $key)) { + return $value; + } + } + + return $default; } - /** @return TValue|null */ - public function last(): ?object + /** + * Get the last item from the collection. + * + * @template TLastDefault + * + * @param (callable(TValue, TKey): bool)|null $callback + * @param TLastDefault|(\Closure(): TLastDefault) $default + * @return TValue|TLastDefault|null + */ + public function last(?callable $callback = null, mixed $default = null): mixed { - return $this->get(count($this->items) - 1); + /** @var TLastDefault $default */ + $default = is_callable($default) + ? call_user_func($default) + : $default; + + if (is_null($callback)) { + if ($this->isEmpty()) { + return $default; + } + + foreach (array_reverse($this->items, true) as $item) { + return $item; + } + + return $default; // @codeCoverageIgnore + } + + foreach (array_reverse($this->items, true) as $key => $value) { + if ($callback($value, $key)) { + return $value; + } + } + + return $default; } /** - * @param TKey $key - * @return TValue|null + * Get an item from the collection by key. + * + * @template TGetDefault + * + * @param (int&TKey)|(string&TKey) $key + * @param TGetDefault|(\Closure(): TGetDefault) $default + * @return TValue|TGetDefault */ - public function get(int|string $key): ?object + public function get(string|int $key, mixed $default = null) { - return $this->items[$key] ?? null; + if (array_key_exists($key, $this->items)) { + return $this->items[$key]; + } + + return is_callable($default) + ? call_user_func($default) + : $default; } + /** + * Get the keys of the collection items. + * + * @return self + */ + public function keys(): static // @phpstan-ignore-line + { + // @phpstan-ignore-next-line + return new static(array_keys($this->items)); + } + + /** + * Count the number of items in the collection. + */ public function count(): int { return count($this->items); } + /** + * Determine if the collection is empty or not. + * + * @phpstan-assert-if-true null $this->first() + * + * @phpstan-assert-if-false TValue $this->first() + */ public function isEmpty(): bool { return empty($this->items); } + /** + * Determine if the collection is not empty. + * + * @phpstan-assert-if-true TValue $this->first() + * + * @phpstan-assert-if-false null $this->first() + */ public function isNotEmpty(): bool { - return !empty($this->items); + return !$this->isEmpty(); + } + + /** + * Push one or more items onto the end of the collection. + * + * @param TValue ...$values + */ + public function push(mixed ...$values): static + { + foreach ($values as $value) { + $this->items[] = $value; + } + + return $this; + } + + /** + * Merge the collection with the given items. + * + * @param iterable $items + */ + public function merge(iterable $items): static + { + // @phpstan-ignore-next-line + return new static(array_merge($this->items, $items)); + } + + /** + * Push all of the given items onto the collection. + * + * @param iterable<(int&TKey)|(string&TKey), TValue> $source + * @return static + */ + public function concat(iterable $source): static // @phpstan-ignore-line + { + // @phpstan-ignore-next-line + $result = new static($this->items); + + foreach ($source as $item) { + $result->push($item); + } + + return $result; + } + + /** + * Put an item in the collection by key. + * + * @param (int&TKey)|(string&TKey) $key + * @param TValue $value + */ + public function put(string|int $key, mixed $value): static + { + $this->offsetSet($key, $value); + + return $this; + } + + /** + * Remove an item from the collection by key. + * + * @param (iterable)|(int&TKey)|(string&TKey) $keys + */ + public function forget(iterable|string|int $keys): static + { + $keys = !is_iterable($keys) ? func_get_args() : $keys; + + /** @var TKey $key */ + foreach ($keys as $key) { + $this->offsetUnset($key); + } + + return $this; + } + + /** + * Get a value from the array, and remove it. + * + * @param (int&TKey)|(string&TKey) $key + */ + public function pull(string|int $key, mixed $default = null): mixed + { + $value = $this->get($key, $default); + + $this->forget($key); + + return $value; + } + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TValue, TKey): TMapValue $callback + * @return static + */ + public function map(callable $callback) + { + $keys = array_keys($this->items); + + try { + $items = array_map($callback, $this->items, $keys); + + // @codeCoverageIgnoreStart + } catch (ArgumentCountError) { + $items = array_map($callback, $this->items); // @phpstan-ignore-line + } + // @codeCoverageIgnoreEnd + + // @phpstan-ignore-next-line + return new static(array_combine($keys, $items)); + } + + /** + * Execute a callback over each item. + * + * @param callable(TValue, TKey): mixed $callback + * @return $this + */ + public function each(callable $callback): static + { + foreach ($this as $key => $item) { + if ($callback($item, $key) === false) { + break; // @codeCoverageIgnore + } + } + + return $this; + } + + /** + * Run a filter over each of the items. + * + * @param (callable(TValue, TKey): bool)|null $callback + * @param int $mode - Available options are: 0: ARRAY_FILTER_USE_VALUE, 1: ARRAY_FILTER_USE_BOTH or 2: ARRAY_FILTER_USE_KEY + * @return static + */ + public function filter(?callable $callback = null, int $mode = ARRAY_FILTER_USE_BOTH) + { + if ($callback) { + // @phpstan-ignore-next-line + return new static(array_filter($this->items, $callback, $mode)); + } + + // @phpstan-ignore-next-line + return new static(array_filter($this->items)); + } + + /** + * Convert the collection to its array representation. + * + * @return array> + */ + public function toArray(): array + { + $items = $this->items; + + foreach ($items as $key => $item) { + $items[$key] = match (true) { + $item instanceof Arrayable => $item->toArray(), + $item instanceof JsonSerializable => $item->jsonSerialize(), + default => $item, + }; + } + + return $items; } - /** @return Traversable */ + /** @return ArrayIterator<(int&TKey)|(string&TKey), TValue> */ public function getIterator(): Traversable { return new ArrayIterator($this->items); @@ -84,16 +356,18 @@ public function offsetExists(mixed $offset): bool } /** - * @param TKey $offset + * @param (int&TKey)|(string&TKey) $offset * @return TValue|null */ - public function offsetGet(mixed $offset): ?object + public function offsetGet(mixed $offset): mixed { return $this->get($offset); } /** - * @param TKey $offset + * Set an item in the collection by key. + * + * @param (int&TKey)|(string&TKey) $offset * @param TValue $value */ public function offsetSet(mixed $offset, mixed $value): void diff --git a/src/Tests/Http/RequestFake.php b/src/Tests/Http/RequestFake.php index 7efb030..e66a1f4 100644 --- a/src/Tests/Http/RequestFake.php +++ b/src/Tests/Http/RequestFake.php @@ -49,7 +49,7 @@ class RequestFake implements Request public function __construct(?Auth $auth = null, ?Config $config = null) { - $this->auth = $auth ?? azure_app(SharedKeyAuth::class, ['account' => 'account', 'key' => 'key']); + $this->auth = $auth ?? azure_app(SharedKeyAuth::class, ['config' => ['account' => 'account', 'key' => 'key']]); $this->config = $config ?? azure_app(Config::class); } diff --git a/tests/Feature/Authentication/MicrosoftEntraIdTest.php b/tests/Feature/Authentication/MicrosoftEntraIdTest.php index 8f0e908..8172c0d 100644 --- a/tests/Feature/Authentication/MicrosoftEntraIdTest.php +++ b/tests/Feature/Authentication/MicrosoftEntraIdTest.php @@ -5,6 +5,7 @@ use Xray\AzureStoragePhpSdk\Authentication\MicrosoftEntraId; use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; +use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; use Xray\Tests\Fakes\ClientFake; @@ -15,15 +16,44 @@ ->toImplement(Auth::class); }); +it('should fail if any required field is missing', function (string $field) { + $config = [ + 'account' => 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => 'secret', + ]; + + unset($config[$field]); + + expect(fn () => new MicrosoftEntraId($config)) // @phpstan-ignore-line + ->toThrow(RequiredFieldException::class, "Missing required parameters: {$field}"); +})->with([ + 'Missing Account' => ['account'], + 'Missing Directory' => ['directory'], + 'Missing Application' => ['application'], + 'Missing Secret' => ['secret'], +]); + it('should get date formatted correctly', function () { - $auth = new MicrosoftEntraId('account', 'directory', 'application', 'secret'); + $auth = new MicrosoftEntraId([ + 'account' => 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => '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'); + $auth = new MicrosoftEntraId([ + 'account' => 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => 'secret', + ]); expect($auth->getAccount()) ->toBe('account'); @@ -40,8 +70,12 @@ $client = (new ClientFake()) ->withResponseFake($body); - $auth = (new MicrosoftEntraId('account', 'directory', $application = 'application', $secret = 'secret')) - ->withRequestClient($client); + $auth = (new MicrosoftEntraId([ + 'account' => 'account', + 'directory' => 'directory', + 'application' => $application = 'application', + 'secret' => $secret = 'secret', + ]))->withRequestClient($client); expect($auth->getAuthentication(new RequestFake())) ->toBe("{$tokeType} {$token}"); diff --git a/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php b/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php index 21748e3..3c8827d 100644 --- a/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php +++ b/tests/Feature/Authentication/SharedAccessSignature/UserDelegationSasTest.php @@ -19,7 +19,7 @@ }); it('should throw an exception if the authentication method is not supported', function () { - $request = new RequestFake(new SharedKeyAuth('account', 'key')); + $request = new RequestFake(new SharedKeyAuth(['account' => 'account', 'key' => 'key'])); (new UserDelegationSas($request)) ->buildTokenUrl(AccessTokenPermission::READ, new DateTimeImmutable()); @@ -39,8 +39,12 @@ XML; - $request = (new RequestFake(new MicrosoftEntraId('account', 'directory', 'application', 'secret'))) - ->withFakeResponse(new ResponseFake($body)); + $request = (new RequestFake(new MicrosoftEntraId([ + 'account' => 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => 'secret', + ])))->withFakeResponse(new ResponseFake($body)); (new UserDelegationSas($request)) ->buildTokenUrl(AccessTokenPermission::READ, new DateTimeImmutable()); @@ -75,7 +79,12 @@ $container = 'container'; $blob = 'blob.txt'; - $request = (new RequestFake(new MicrosoftEntraId($account = 'account', 'directory', 'application', 'secret'))) + $request = (new RequestFake(new MicrosoftEntraId([ + 'account' => $account = 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => 'secret', + ]))) ->withFakeResponse(new ResponseFake($body)) ->withResource("/{$container}/{$blob}"); diff --git a/tests/Feature/Authentication/SharedKeyAuthTest.php b/tests/Feature/Authentication/SharedKeyAuthTest.php index 217e4c2..d1151eb 100644 --- a/tests/Feature/Authentication/SharedKeyAuthTest.php +++ b/tests/Feature/Authentication/SharedKeyAuthTest.php @@ -6,6 +6,7 @@ use Xray\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Xray\AzureStoragePhpSdk\BlobStorage\Resource; use Xray\AzureStoragePhpSdk\Contracts\Authentication\Auth; +use Xray\AzureStoragePhpSdk\Exceptions\RequiredFieldException; use Xray\AzureStoragePhpSdk\Http\Headers; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; @@ -16,15 +17,30 @@ ->toImplement(Auth::class); }); +it('should fail if any required field is missing', function (string $field) { + $config = [ + 'account' => 'account', + 'key' => 'key', + ]; + + unset($config[$field]); + + expect(fn () => new SharedKeyAuth($config)) // @phpstan-ignore-line + ->toThrow(RequiredFieldException::class, "Missing required parameters: {$field}"); +})->with([ + 'Missing Account' => ['account'], + 'Missing Key' => ['key'], +]); + it('should get date formatted correctly', function () { - $auth = new SharedKeyAuth('account', 'key'); + $auth = new SharedKeyAuth(['account' => 'account', 'key' => 'key']); expect($auth->getDate()) ->toBe(gmdate('D, d M Y H:i:s T')); }); it('should get the authentication account', function () { - $auth = new SharedKeyAuth('account', 'key'); + $auth = new SharedKeyAuth(['account' => 'account', 'key' => 'key']); expect($auth->getAccount()) ->toBe('account'); @@ -33,7 +49,7 @@ 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)); + $auth = new SharedKeyAuth(['account' => $account = 'account', 'key' => base64_encode($decodedKey)]); $request = (new RequestFake()) ->withVerb($verb); @@ -57,7 +73,7 @@ it('should get correctly the authentication signature for all headers', function (string $headerMethod, int|string $headerValue) { $decodedKey = 'my-decoded-account-key'; - $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); + $auth = new SharedKeyAuth(['account' => $account = 'account', 'key' => base64_encode($decodedKey)]); $request = (new RequestFake()) ->withVerb(HttpVerb::GET) @@ -86,7 +102,7 @@ it('should get correctly the authentication signature for all canonical headers', function (string $headerMethod, string $headerValue) { $decodedKey = 'my-decoded-account-key'; - $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); + $auth = new SharedKeyAuth(['account' => $account = 'account', 'key' => base64_encode($decodedKey)]); $request = (new RequestFake()) ->withVerb(HttpVerb::GET) diff --git a/tests/Feature/BlobStorage/BlobStorageTest.php b/tests/Feature/BlobStorage/BlobStorageClientTest.php similarity index 64% rename from tests/Feature/BlobStorage/BlobStorageTest.php rename to tests/Feature/BlobStorage/BlobStorageClientTest.php index 7a1c8be..9c28c89 100644 --- a/tests/Feature/BlobStorage/BlobStorageTest.php +++ b/tests/Feature/BlobStorage/BlobStorageClientTest.php @@ -3,9 +3,9 @@ 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\{BlobStorageClient, Config}; use Xray\AzureStoragePhpSdk\Tests\Http\RequestFake; uses()->group('blob-storage'); @@ -21,8 +21,22 @@ 'Blob Manager' => ['blobs', BlobManager::class, ['test']], ]); +it('should get the underneath request', function () { + $request = new RequestFake(); + + expect((new BlobStorageClient($request))->getRequest()) + ->toBeInstanceOf(RequestFake::class); +}); + +it('should get the underneath config', function () { + $request = new RequestFake(); + + expect((new BlobStorageClient($request))->getConfig()) + ->toBeInstanceOf(Config::class); +}); + it('should create a new blob storage client', function () { - $auth = new SharedKeyAuth('account', 'key'); + $auth = new SharedKeyAuth(['account' => 'account', 'key' => 'key']); expect(BlobStorageClient::create($auth)) ->toBeInstanceOf(BlobStorageClient::class); diff --git a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php index 26c34b1..6c1710a 100644 --- a/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/Blob/BlobStorageBlobManagerTest.php @@ -369,8 +369,12 @@ XML; - $request = (new RequestFake(new MicrosoftEntraId('account', 'directory', 'application', 'secret'))) - ->withFakeResponse(new ResponseFake($body)); + $request = (new RequestFake(new MicrosoftEntraId([ + 'account' => 'account', + 'directory' => 'directory', + 'application' => 'application', + 'secret' => 'secret', + ])))->withFakeResponse(new ResponseFake($body)); $container = 'container'; $blob = 'blob.txt'; diff --git a/tests/Feature/Http/RequestTest.php b/tests/Feature/Http/RequestTest.php index 4252962..3c13d1a 100644 --- a/tests/Feature/Http/RequestTest.php +++ b/tests/Feature/Http/RequestTest.php @@ -12,7 +12,7 @@ uses()->group('http'); it('should send get, delete, and options requests', function (string $method, HttpVerb $verb): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: $client = new ClientFake())) ->withAuthentication() @@ -46,7 +46,7 @@ ]); it('should send post and put requests', function (string $method, HttpVerb $verb): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: $client = new ClientFake())) ->withoutAuthentication() @@ -86,7 +86,7 @@ ]); it('should get request config', function (): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $config = new Config(); expect((new Request($auth, $config, new ClientFake()))->getConfig()) @@ -94,14 +94,14 @@ }); it('should get request auth', function (): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => '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'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: new ClientFake())) ->withVerb($verb); @@ -112,7 +112,7 @@ })->with(fn () => HttpVerb::cases()); it('should get the resource from request', function (): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: new ClientFake())) ->withResource('endpoint'); @@ -122,7 +122,7 @@ }); it('should get the headers from request', function (): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: new ClientFake())) ->withHttpHeaders(new Headers()); @@ -132,7 +132,7 @@ }); it('should get the body from request', function (): void { - $auth = new SharedKeyAuth('my_account', 'bar'); + $auth = new SharedKeyAuth(['account' => 'my_account', 'key' => 'bar']); $request = (new Request($auth, client: new ClientFake())) ->withBody('body'); diff --git a/tests/Feature/Support/CollectionTest.php b/tests/Feature/Support/CollectionTest.php index 43021b0..9e407b6 100644 --- a/tests/Feature/Support/CollectionTest.php +++ b/tests/Feature/Support/CollectionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Xray\AzureStoragePhpSdk\Contracts\Arrayable; use Xray\AzureStoragePhpSdk\Support\Collection; uses()->group('supports'); @@ -94,3 +95,230 @@ expect($iterations)->toBe(3); }); + +it('should get the first item', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]; + + $findItem = function (int $id): callable { + return function (object $item) use ($id): bool { + /** @var object{id: int, text: string} $item */ + return $item->id === $id; + }; + }; + + expect(new Collection($items)) + ->first()->text->toBe('test') + ->first($findItem(2))->text->toBe('something') + ->first($findItem(4))->toBeNull() + ->first($findItem(4), fn () => (object) ['text' => 'new'])->text->toBe('new') + ->and(new Collection()) + ->first()->toBeNull(); +}); + +it('should get the last item', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]; + + $findItem = function (int $id): callable { + return function (object $item) use ($id): bool { + /** @var object{id: int, text: string} $item */ + return $item->id === $id; + }; + }; + + expect(new Collection($items)) + ->last()->text->toBe('other') + ->last($findItem(2))->text->toBe('something') + ->last($findItem(4))->toBeNull() + ->last($findItem(4), fn () => (object) ['text' => 'new'])->text->toBe('new') + ->and(new Collection()) + ->last()->toBeNull(); +}); + +it('should be able to get an item out of the collection', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]; + + expect(new Collection($items)) + ->get(1)->text->toBe('something') + ->get(3)->toBeNull() + ->get(4, fn () => 'test')->toBe('test'); +}); + +it('should get the collection\'s keys', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]; + + expect(new Collection($items)) + ->keys()->all()->toBe([0, 1, 2]); +}); + +it('should push items into the collection', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + ]; + + $collection = (new Collection($items)) + ->push((object)['id' => 2, 'text' => 'something']); + + expect($collection) + ->first()->id->toBe(1) + ->last()->id->toBe(2); +}); + +it('should merge items into the collection', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + ]; + + $collection = (new Collection($items)) + ->merge([(object)['id' => 3, 'text' => 'something']]); + + expect($collection) + ->first()->id->toBe(1) + ->last()->id->toBe(3); +}); + +it('should concat items to the collection', function () { + $items = [ + (object)['id' => 1, 'text' => 'test'], + ]; + + $collection = (new Collection($items)) + ->concat([(object)['id' => 4, 'text' => 'something']]); + + expect($collection) + ->first()->id->toBe(1) + ->last()->id->toBe(4); +}); + +it('should put a new value into a collection key', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + ]); + + $collection->put(0, (object)['id' => 2, 'text' => 'something']); + + expect($collection) + ->first()->id->toBe(2); +}); + +it('should forget a value from the collection', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]); + + $collection->forget(1); + + expect($collection) + ->count()->toBe(2); + + $collection->forget([0, 2]); + + expect($collection) + ->isEmpty()->toBeTrue(); +}); + +it('should pull an item out of the collection', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]); + + expect($collection->pull(1)) + ->id->toBe(2); + + expect($collection) + ->count()->toBe(2); +}); + +it('should map the collection', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]); + + $result = $collection->map(fn (object $item) => $item->text); + + expect($result) + ->all() + ->toBe(['test', 'something', 'other']); +}); + +it('should loop over the collection', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]); + + $iterations = 0; + + $collection->each(function (object $item, int $index) use (&$iterations) { + $iterations++; + + expect($item) + ->id->toBe($index + 1); + }); + + expect($iterations)->toBe(3); +}); + +it('should filter the collection', function () { + $collection = new Collection([ + (object)['id' => 1, 'text' => 'test'], + (object)['id' => 2, 'text' => 'something'], + (object)['id' => 3, 'text' => 'other'], + ]); + + expect($collection->filter(fn (object $item) => $item->id > 1)) + ->count()->toBe(2) + ->first()->id->toBe(2) + ->last()->id->toBe(3); + + $collection = new Collection(['test', '', null, 0]); + + expect($collection->filter()) + ->count()->toBe(1) + ->first()->toBe('test'); +}); + +it('should get collection as array', function () { + $collection = new Collection([ + 'test', + new class () implements Arrayable { + /** @return array */ + public function toArray(): array + { + return ['array']; + } + }, + new class () implements JsonSerializable { + /** @return array */ + public function jsonSerialize(): array + { + return ['json']; + } + }, + ]); + + expect($collection->toArray()) + ->toBe(['test', ['array'], ['json']]); +}); diff --git a/tests/Unit/Exceptions/RequiredFieldExceptionTest.php b/tests/Unit/Exceptions/RequiredFieldExceptionTest.php index bf32c0d..4d62dd5 100644 --- a/tests/Unit/Exceptions/RequiredFieldExceptionTest.php +++ b/tests/Unit/Exceptions/RequiredFieldExceptionTest.php @@ -11,6 +11,12 @@ ->toExtend(Exception::class); }); +it('should create a new required field exception', function () { + expect(RequiredFieldException::create('This is a new required field exception')) + ->getMessage()->toBe('This is a new required field exception') + ->getCode()->toBe(0); +}); + it('should be a required field exception', function () { expect(RequiredFieldException::missingField('name')) ->getMessage()->toBe('Field [name] is required')