diff --git a/README.md b/README.md index 3398c0b..db6ad3f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![PHP CI](https://github.com/sjspereira/azure-storage-php-sdk/actions/workflows/CI.yaml/badge.svg)](https://github.com/sjspereira/azure-storage-php-sdk/actions/workflows/CI.yaml) + ## Description Integrate with Azure's cloud storage services diff --git a/src/Authentication/MicrosoftEntraId.php b/src/Authentication/MicrosoftEntraId.php new file mode 100644 index 0000000..2906053 --- /dev/null +++ b/src/Authentication/MicrosoftEntraId.php @@ -0,0 +1,76 @@ +account; + } + + public function getAuthentication( + HttpVerb $verb, + Headers $headers, + string $resource, + ): string { + if (!empty($this->token) && $this->tokenExpiresAt > new DateTime()) { + return $this->token; + } + + $this->authenticate(); + + return $this->token; + } + + protected function authenticate(): void + { + try { + $response = (new Client())->post("https://login.microsoftonline.com/{$this->directoryId}/oauth2/v2.0/token", [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->applicationId, + 'client_secret' => $this->applicationSecret, + 'scope' => 'https://storage.azure.com/.default', + ], + ]); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + + /** @var array{token_type: string, expires_in: int, access_token: string} */ + $body = json_decode((string) $response->getBody(), true); + + $this->token = "{$body['token_type']} {$body['access_token']}"; + + $this->tokenExpiresAt = (new DateTime())->modify("+{$body['expires_in']} seconds"); + } +} diff --git a/src/Authentication/SharedKeyAuth.php b/src/Authentication/SharedKeyAuth.php index 40531e7..0133254 100644 --- a/src/Authentication/SharedKeyAuth.php +++ b/src/Authentication/SharedKeyAuth.php @@ -4,14 +4,13 @@ namespace Sjpereira\AzureStoragePhpSdk\Authentication; -use Sjpereira\AzureStoragePhpSdk\BlobStorage\Config; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Sjpereira\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Sjpereira\AzureStoragePhpSdk\Http\Headers; final class SharedKeyAuth implements Auth { - public function __construct(protected Config $config) + public function __construct(protected string $account, protected string $key) { // } @@ -21,12 +20,17 @@ 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 { - $key = base64_decode($this->config->key); + $key = base64_decode($this->key); $stringToSign = $this->getSigningString( $verb->value, @@ -37,11 +41,11 @@ public function getAuthentication( $signature = base64_encode(hash_hmac('sha256', $stringToSign, $key, true)); - return "SharedKey {$this->config->account}:{$signature}"; + return "SharedKey {$this->account}:{$signature}"; } protected function getSigningString(string $verb, string $headers, string $canonicalHeaders, string $resource): string { - return "{$verb}\n{$headers}\n{$canonicalHeaders}\n/{$this->config->account}{$resource}"; + return "{$verb}\n{$headers}\n{$canonicalHeaders}\n/{$this->account}{$resource}"; } } diff --git a/src/BlobStorage/BlobStorage.php b/src/BlobStorage/BlobStorage.php index e12fea6..ea06b89 100644 --- a/src/BlobStorage/BlobStorage.php +++ b/src/BlobStorage/BlobStorage.php @@ -6,10 +6,7 @@ use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobManager; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\{AccountManager, ContainerManager}; -use Sjpereira\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Sjpereira\AzureStoragePhpSdk\Contracts\Http\Request as RequestContract; -use Sjpereira\AzureStoragePhpSdk\Contracts\{Converter, Parser}; -use Sjpereira\AzureStoragePhpSdk\Http\Request; final class BlobStorage { @@ -18,14 +15,6 @@ public function __construct(protected RequestContract $request) // } - /** @param array{account: string, key: string, version?: string, parser?: Parser, converter?: Converter, auth?: Auth} $options */ - public static function client(array $options, ?RequestContract $request = null): self - { - $config = new Config($options); - - return new self($request ?? new Request($config)); - } - public function account(): AccountManager { return new AccountManager($this->request); diff --git a/src/BlobStorage/Config.php b/src/BlobStorage/Config.php index f954e74..298b1f5 100644 --- a/src/BlobStorage/Config.php +++ b/src/BlobStorage/Config.php @@ -4,7 +4,6 @@ namespace Sjpereira\AzureStoragePhpSdk\BlobStorage; -use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Sjpereira\AzureStoragePhpSdk\Contracts\{Converter, Parser}; use Sjpereira\AzureStoragePhpSdk\Converter\XmlConverter; @@ -12,41 +11,24 @@ use Sjpereira\AzureStoragePhpSdk\Parsers\XmlParser; /** - * @phpstan-type ConfigType array{account: string, key: string, version?: string, parser?: Parser, converter?: Converter, auth?: Auth} + * @phpstan-type ConfigType array{version?: string, parser?: Parser, converter?: Converter} */ final readonly class Config { - public string $account; - - public string $key; - public string $version; public Parser $parser; public Converter $converter; - public Auth $auth; - /** * @param ConfigType $config * @throws InvalidArgumentException */ - public function __construct(array $config) + public function __construct(public Auth $auth, array $config = []) { - if (empty($config['account'] ?? null)) { // @phpstan-ignore-line - throw InvalidArgumentException::create('Account name must be provided.'); - } - - if (empty($config['key'] ?? null)) { // @phpstan-ignore-line - throw InvalidArgumentException::create('Account key must be provided.'); - } - - $this->account = $config['account']; - $this->key = $config['key']; $this->version = $config['version'] ?? Resource::VERSION; $this->parser = $config['parser'] ?? new XmlParser(); $this->converter = $config['converter'] ?? new XmlConverter(); - $this->auth = $config['auth'] ?? new SharedKeyAuth($this); } } diff --git a/src/BlobStorage/Entities/Blob/Blob.php b/src/BlobStorage/Entities/Blob/Blob.php index c7baa80..2633481 100644 --- a/src/BlobStorage/Entities/Blob/Blob.php +++ b/src/BlobStorage/Entities/Blob/Blob.php @@ -4,8 +4,10 @@ namespace Sjpereira\AzureStoragePhpSdk\BlobStorage\Entities\Blob; +use DateTime; use DateTimeImmutable; -use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobManager; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\ExpirationOption; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob\{BlobLeaseManager, BlobManager, BlobTagManager}; use Sjpereira\AzureStoragePhpSdk\Concerns\HasManager; use Sjpereira\AzureStoragePhpSdk\Exceptions\RequiredFieldException; @@ -21,10 +23,14 @@ final class Blob public readonly string $name; - public readonly DateTimeImmutable $snapshot; + public readonly ?DateTimeImmutable $snapshot; + + public readonly ?string $snapshotOriginalRaw; public readonly DateTimeImmutable $versionId; + public readonly ?string $versionIdOriginalRaw; + public readonly bool $isCurrentVersion; public readonly bool $deleted; @@ -41,10 +47,12 @@ public function __construct(array $blob) throw RequiredFieldException::missingField('Name'); } - $this->name = $name; - $this->snapshot = new DateTimeImmutable($blob['Snapshot'] ?? 'now'); - $this->versionId = new DateTimeImmutable($blob['Version'] ?? 'now'); - $this->isCurrentVersion = to_boolean($blob['IsCurrentVersion'] ?? true); + $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'] ?? []); @@ -58,4 +66,66 @@ public function get(array $options = []): File return $this->getManager()->get($this->name, $options); } + + /** @param array $options */ + public function getProperties(array $options = []): BlobProperty + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->properties($this->name)->get($options); + } + + /** + * @param boolean $force If true, Delete the base blob and all of its snapshots. + */ + public function delete(bool $force = false): bool + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->delete($this->name, $this->snapshotOriginalRaw, $force); + } + + /** @param array $options */ + public function copy(string $destination, array $options = []): bool + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->copy($this->name, $destination, $options, $this->snapshotOriginalRaw); + } + + public function restore(): bool + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->restore($this->name); + } + + public function createSnapshot(): bool + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->createSnapshot($this->name); + } + + public function tags(): BlobTagManager + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->tags($this->name); + } + + public function lease(): BlobLeaseManager + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->lease($this->name); + } + + /** @param array $options */ + public function setExpiry(ExpirationOption $option, null|int|DateTime $expiry, array $options = []): bool + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->setExpiry($this->name, $option, $expiry, $options); + } } diff --git a/src/BlobStorage/Entities/Blob/BlobLease.php b/src/BlobStorage/Entities/Blob/BlobLease.php new file mode 100644 index 0000000..135aca7 --- /dev/null +++ b/src/BlobStorage/Entities/Blob/BlobLease.php @@ -0,0 +1,80 @@ + */ + use HasManager; + + public readonly DateTimeImmutable $lastModified; + + public readonly string $etag; + + public readonly string $server; + + public readonly string $requestId; + + public readonly string $version; + + public readonly ?string $leaseId; + + public readonly DateTimeImmutable $date; + + /** @param BlobLeaseType $blobLease */ + public function __construct(array $blobLease) + { + $this->lastModified = new DateTimeImmutable($blobLease['Last-Modified'] ?? 'now'); + $this->etag = $blobLease['ETag'] ?? ''; + $this->server = $blobLease['Server'] ?? ''; + $this->requestId = $blobLease[Resource::REQUEST_ID] ?? ''; + $this->version = $blobLease[Resource::AUTH_VERSION] ?? ''; + $this->date = new DateTimeImmutable($blobLease['Date'] ?? 'now'); + + $this->leaseId = $blobLease[Resource::LEASE_ID] + ?? null; + } + + public function renew(): self + { + $this->ensureLeaseIdIsset(); + + return $this->manager->renew($this->leaseId); + } + + public function change(string $toLeaseId): self + { + $this->ensureLeaseIdIsset(); + + return $this->manager->change($this->leaseId, $toLeaseId); + } + + public function release(string $leaseId): self + { + return $this->manager->release($leaseId); + } + + public function break(?string $leaseId = null): self + { + return $this->manager->break($leaseId); + } + + /** @phpstan-assert string $this->leaseId */ + protected function ensureLeaseIdIsset(): void + { + if (empty($this->leaseId)) { + throw RequiredFieldException::missingField('leaseId'); + } + } +} diff --git a/src/BlobStorage/Entities/Blob/Properties.php b/src/BlobStorage/Entities/Blob/Properties.php index d62ac63..009f6e1 100644 --- a/src/BlobStorage/Entities/Blob/Properties.php +++ b/src/BlobStorage/Entities/Blob/Properties.php @@ -105,7 +105,7 @@ public function __construct(array $property) $this->resourceType = $property['ResourceType'] ?? ''; $this->placeholder = $property['Placeholder'] ?? ''; $this->contentLength = $property['Content-Length'] ?? ''; - $this->contentType = $property['Content-Type'] ?? ''; + $this->contentType = json_encode($property['Content-Type'] ?? []) ?: ''; $this->contentEncoding = json_encode($property['Content-Encoding'] ?? []) ?: ''; $this->contentLanguage = json_encode($property['Content-Language'] ?? []) ?: ''; $this->contentMD5 = json_encode($property['Content-MD5'] ?? []) ?: ''; diff --git a/src/BlobStorage/Entities/Container/Container.php b/src/BlobStorage/Entities/Container/Container.php index 255f2a8..925bfbf 100644 --- a/src/BlobStorage/Entities/Container/Container.php +++ b/src/BlobStorage/Entities/Container/Container.php @@ -6,6 +6,7 @@ use Sjpereira\AzureStoragePhpSdk\BlobStorage\Entities\Container\AccessLevel\ContainerAccessLevels; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobManager; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Container\ContainerLeaseManager; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\ContainerManager; use Sjpereira\AzureStoragePhpSdk\Concerns\HasManager; use Sjpereira\AzureStoragePhpSdk\Exceptions\RequiredFieldException; @@ -79,6 +80,13 @@ public function restore(): bool return $this->getManager()->restore($this->name, $this->version); } + public function lease(): ContainerLeaseManager + { + $this->ensureManagerIsConfigured(); + + return $this->getManager()->lease($this->name); + } + public function blobs(): BlobManager { $this->ensureManagerIsConfigured(); diff --git a/src/BlobStorage/Enums/BlobIncludeOption.php b/src/BlobStorage/Enums/BlobIncludeOption.php new file mode 100644 index 0000000..559dfbc --- /dev/null +++ b/src/BlobStorage/Enums/BlobIncludeOption.php @@ -0,0 +1,26 @@ + $enum->value, self::cases()); + } +} diff --git a/src/BlobStorage/Enums/ExpirationOption.php b/src/BlobStorage/Enums/ExpirationOption.php new file mode 100644 index 0000000..7da9409 --- /dev/null +++ b/src/BlobStorage/Enums/ExpirationOption.php @@ -0,0 +1,13 @@ +request(array_filter([ + Resource::LEASE_ACTION => 'acquire', + Resource::LEASE_DURATION => $duration, + Resource::LEASE_ID => $leaseId, + ]))->getHeaders(); + + return (new BlobLease($headers)) + ->setManager($this); + } + + public function renew(string $leaseId): BlobLease + { + /** @var array{'Last-Modified'?: string, ETag?: string, Server?: string, Date?: string, 'x-ms-request-id'?: string, 'x-ms-version'?: string, 'x-ms-lease-id'?: string} $headers */ + $headers = $this->request([ + Resource::LEASE_ACTION => 'renew', + Resource::LEASE_ID => $leaseId, + ])->getHeaders(); + + return (new BlobLease($headers)) + ->setManager($this); + } + + public function change(string $fromLeaseId, string $toLeaseId): BlobLease + { + /** @var array{'Last-Modified'?: string, ETag?: string, Server?: string, Date?: string, 'x-ms-request-id'?: string, 'x-ms-version'?: string, 'x-ms-lease-id'?: string} $headers */ + $headers = $this->request([ + Resource::LEASE_ACTION => 'change', + Resource::LEASE_ID => $fromLeaseId, + Resource::LEASE_PROPOSED_ID => $toLeaseId, + ])->getHeaders(); + + return (new BlobLease($headers)) + ->setManager($this); + } + + public function release(string $leaseId): BlobLease + { + /** @var array{'Last-Modified'?: string, ETag?: string, Server?: string, Date?: string, 'x-ms-request-id'?: string, 'x-ms-version'?: string, 'x-ms-lease-id'?: string} $headers */ + $headers = $this->request([ + Resource::LEASE_ACTION => 'release', + Resource::LEASE_ID => $leaseId, + ])->getHeaders(); + + return (new BlobLease($headers)) + ->setManager($this); + } + + public function break(?string $leaseId = null): BlobLease + { + /** @var array{'Last-Modified'?: string, ETag?: string, Server?: string, Date?: string, 'x-ms-request-id'?: string, 'x-ms-version'?: string, 'x-ms-lease-id'?: string} $headers */ + $headers = $this->request(array_filter([ + Resource::LEASE_ACTION => 'break', + Resource::LEASE_ID => $leaseId, + ]))->getHeaders(); + + return (new BlobLease($headers)) + ->setManager($this); + } + + /** @param array $headers */ + protected function request(array $headers): Response + { + try { + return $this->request + ->withHeaders($headers) + ->put("{$this->container}/{$this->blob}?comp=lease&resttype=blob"); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } +} diff --git a/src/BlobStorage/Managers/Blob/BlobManager.php b/src/BlobStorage/Managers/Blob/BlobManager.php index 06dc903..b489902 100644 --- a/src/BlobStorage/Managers/Blob/BlobManager.php +++ b/src/BlobStorage/Managers/Blob/BlobManager.php @@ -4,13 +4,16 @@ namespace Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob; +use DateTime; +use DateTimeImmutable; use Psr\Http\Client\RequestExceptionInterface; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Entities\Blob\{Blob, Blobs, File}; -use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\BlobType; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\{BlobIncludeOption, BlobType, ExpirationOption}; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\Queries\BlobTagQuery; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Resource; use Sjpereira\AzureStoragePhpSdk\Contracts\Http\Request; use Sjpereira\AzureStoragePhpSdk\Contracts\Manager; -use Sjpereira\AzureStoragePhpSdk\Exceptions\RequestException; +use Sjpereira\AzureStoragePhpSdk\Exceptions\{InvalidArgumentException, RequestException}; /** * @phpstan-import-type BlobType from Blob as BlobTypeStan @@ -23,13 +26,26 @@ public function __construct(protected Request $request, protected string $contai // } - /** @param array $options */ - public function list(array $options = [], bool $withDeleted = false): Blobs + /** + * @param array $options + * @param string[] $includes + */ + public function list(array $options = [], array $includes = []): Blobs { + if (array_diff($includes, $availableOptions = BlobIncludeOption::toArray()) !== []) { + throw InvalidArgumentException::create(sprintf("Invalid include option. \nValid options: %s", implode(', ', $availableOptions))); + } + + $include = ''; + + if (!empty($includes)) { + $include = sprintf('&include=%s', implode(',', $includes)); + } + try { $response = $this->request ->withOptions($options) - ->get("{$this->containerName}/?restype=container&comp=list") + ->get("{$this->containerName}/?restype=container&comp=list{$include}") ->getBody(); } catch (RequestExceptionInterface $e) { throw RequestException::createFromRequestException($e); @@ -41,6 +57,33 @@ public function list(array $options = [], bool $withDeleted = false): Blobs return new Blobs($this, $parsed['Blobs']['Blob'] ?? []); } + /** + * Find Blobs by Tags operation finds all blobs in the storage account whose tags match a search expression. + * @param array $options + * @return BlobTagQuery + */ + public function findByTag(array $options = []): BlobTagQuery + { + /** @var BlobTagQuery */ + return (new BlobTagQuery($this)) + ->whenBuild(function (string $query) use ($options): Blobs { + try { + $response = $this->request + ->withOptions($options) + ->get("{$this->containerName}/?restype=container&comp=blobs&where={$query}") + ->getBody(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + + /** @var array{Blobs?: array{Blob: BlobTypeStan|BlobTypeStan[]}} $parsed */ + $parsed = $this->request->getConfig()->parser->parse($response); + + return new Blobs($this, $parsed['Blobs']['Blob'] ?? []); + }); + + } + /** @param array $options */ public function get(string $blobName, array $options = []): File { @@ -81,6 +124,106 @@ public function putBlock(File $file, array $options = []): bool } } + /** @param array $options */ + public function setExpiry(string $blobName, ExpirationOption $expirationOption, null|int|DateTime $expiryTime = null, array $options = []): bool + { + $this->validateExpirationTime($expirationOption, $expiryTime); + + $formattedExpirationTime = $expiryTime instanceof DateTime + ? convert_to_RFC1123($expiryTime) + : $expiryTime; + + try { + return $this->request + ->withOptions($options) + ->withHeaders(array_filter([ + Resource::EXPIRY_OPTION => $expirationOption->value, + Resource::EXPIRY_TIME => $formattedExpirationTime, + ])) + ->put("{$this->containerName}/{$blobName}?resttype=blob&comp=expiry") + ->isOk(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } + + /** + * @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 + { + if ($snapshot instanceof DateTimeImmutable) { + $snapshot = convert_to_RFC3339_micro($snapshot); + } + + $snapshotHeader = $snapshot ? sprintf('?snapshot=%s', urlencode($snapshot)) : ''; + + $deleteSnapshotHeader = $snapshot ? sprintf('&%s=only', Resource::DELETE_SNAPSHOTS) : ''; + + if ($force) { + $deleteSnapshotHeader = sprintf('&%s=include', Resource::DELETE_SNAPSHOTS); + } + + try { + return $this->request + ->delete("{$this->containerName}/{$blobName}?resttype=blob{$snapshotHeader}{$deleteSnapshotHeader}") + ->isAccepted(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } + + public function restore(string $blobName): bool + { + try { + return $this->request + ->put("{$this->containerName}/{$blobName}?comp=undelete&resttype=blob") + ->isOk(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } + + public function createSnapshot(string $blobName): bool + { + try { + return $this->request + ->put("{$this->containerName}/{$blobName}?comp=snapshot&resttype=blob") + ->isCreated(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } + + /** @param array $options */ + public function copy(string $sourceCopy, string $blobName, array $options = [], null|DateTimeImmutable|string $snapshot = null): bool + { + if ($snapshot instanceof DateTimeImmutable) { + $snapshot = convert_to_RFC3339_micro($snapshot); + } + + $snapshotHeader = $snapshot ? sprintf('?snapshot=%s', urlencode($snapshot)) : ''; + + $sourceUri = $this->request->uri("{$this->containerName}/{$sourceCopy}{$snapshotHeader}"); + + try { + return $this->request + ->withOptions($options) + ->withHeaders([ + Resource::COPY_SOURCE => $sourceUri, + ]) + ->put("{$this->containerName}/{$blobName}?resttype=blob") + ->isAccepted(); + } catch (RequestExceptionInterface $e) { + throw RequestException::createFromRequestException($e); + } + } + + public function lease(string $blobName): BlobLeaseManager + { + return new BlobLeaseManager($this->request, $this->containerName, $blobName); + } + public function pages(): BlobPageManager { return (new BlobPageManager($this->request, $this->containerName)) @@ -101,4 +244,16 @@ public function tags(string $blobName): BlobTagManager { return new BlobTagManager($this->request, $this->containerName, $blobName); } + + protected function validateExpirationTime(ExpirationOption $expirationOption, null|int|DateTime $expiryTime = null): void + { + match (true) { + $expirationOption === ExpirationOption::NEVER_EXPIRE && $expiryTime !== null => throw InvalidArgumentException::create('The expiration time must be null when the option is never expire.'), + $expirationOption !== ExpirationOption::NEVER_EXPIRE && $expiryTime === null => throw InvalidArgumentException::create('The expiration time must be informed when the option is not never expire.'), + is_int($expiryTime) && $expirationOption === ExpirationOption::ABSOLUTE => throw InvalidArgumentException::create('The expiration time must be an instance of DateTime.'), + is_int($expiryTime) && $expiryTime < 0 => throw InvalidArgumentException::create('The expiration time must be a positive integer.'), + $expiryTime instanceof DateTime && $expirationOption !== ExpirationOption::ABSOLUTE => throw InvalidArgumentException::create('The expiration time must be informed in milliseconds.'), + default => true, + }; + } } diff --git a/src/BlobStorage/Queries/BlobTagQuery.php b/src/BlobStorage/Queries/BlobTagQuery.php new file mode 100644 index 0000000..c1eff92 --- /dev/null +++ b/src/BlobStorage/Queries/BlobTagQuery.php @@ -0,0 +1,80 @@ + */ + protected array $wheres = []; + + protected Closure $callback; + + /** @param TManager $manager */ + public function __construct(protected Manager $manager) + { + // + } + + /** @return BlobTagQuery */ + public function where(string $tag, string $operator, ?string $value = null): self + { + if (is_null($value)) { + $value = $operator; + $operator = '='; + } + + $this->validateOperator($operator); + + $this->wheres[] = ['tag' => $tag, 'operator' => $operator, 'value' => $value]; + + return $this; + } + + /** + * @param Closure(string $query): TReturn $callback + * @return BlobTagQuery + */ + public function whenBuild(Closure $callback): self + { + $this->callback = $callback; + + return $this; + } + + /** @return TReturn */ + public function build(): object + { + if (!isset($this->callback)) { + throw RequiredFieldException::missingField('callback'); + } + + usort($this->wheres, fn (array $a, array $b) => $a['value'] <=> $b['value']); + + $queries = []; + + foreach ($this->wheres as $where) { + $queries[] = "\"{$where['tag']}\"{$where['operator']}'{$where['value']}'"; + } + + $query = urlencode(implode('AND', $queries)); + + return ($this->callback)($query); + } + + protected function validateOperator(string $operator): void + { + if (!in_array($operator, ['=', '>', '>=', '<', '<='])) { + throw new \InvalidArgumentException("Invalid operator: {$operator}"); + } + } +} diff --git a/src/BlobStorage/Resource.php b/src/BlobStorage/Resource.php index 42cb8e2..3dd2321 100644 --- a/src/BlobStorage/Resource.php +++ b/src/BlobStorage/Resource.php @@ -45,6 +45,14 @@ final class Resource public const string BLOB_SEQUENCE_NUMBER = 'x-ms-blob-sequence-number'; public const string BLOB_TYPE = 'x-ms-blob-type'; + public const string UNDELETE_SOURCE = 'x-ms-undelete-source'; + public const string DELETE_SNAPSHOTS = 'x-ms-delete-snapshots'; + + public const string EXPIRY_OPTION = 'x-ms-expiry-option'; + public const string EXPIRY_TIME = 'x-ms-expiry-time'; + + public const string COPY_SOURCE = 'x-ms-copy-source'; + public const string SEQUENCE_NUMBER_ACTION = 'x-ms-sequence-number-action'; public static function canonicalize(string $uri): string diff --git a/src/Contracts/Authentication/Auth.php b/src/Contracts/Authentication/Auth.php index 6c1a318..1bf649b 100644 --- a/src/Contracts/Authentication/Auth.php +++ b/src/Contracts/Authentication/Auth.php @@ -11,6 +11,8 @@ interface Auth { public function getDate(): string; + public function getAccount(): string; + public function getAuthentication( HttpVerb $verb, Headers $headers, diff --git a/src/Contracts/Http/Request.php b/src/Contracts/Http/Request.php index 61fc750..7bd1566 100644 --- a/src/Contracts/Http/Request.php +++ b/src/Contracts/Http/Request.php @@ -20,4 +20,6 @@ public function withOptions(array $options = []): static; /** @param array $headers */ public function withHeaders(array $headers = []): static; + + public function uri(?string $endpoint = null): string; } diff --git a/src/Http/Request.php b/src/Http/Request.php index d53bf91..aaf0d67 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -135,6 +135,25 @@ public function options(string $endpoint): ResponseContract ); } + public function uri(?string $endpoint = null): string + { + $account = $this->config->auth->getAccount(); + + if (!is_null($this->usingAccountCallback)) { + $account = call_user_func($this->usingAccountCallback, $account); + + $this->usingAccountCallback = null; + } + + if (!is_null($endpoint)) { + [$endpoint, $params] = array_pad(explode('?', $endpoint, 2), 2, ''); + + $endpoint = implode('/', array_map('rawurlencode', explode('/', $endpoint))) . "?{$params}"; + } + + return "{$this->protocol}://{$account}.{$this->baseDomain}/{$endpoint}"; + } + /** @return array */ protected function getOptions(HttpVerb $verb, string $resource, string $body = ''): array { @@ -165,23 +184,4 @@ protected function getOptions(HttpVerb $verb, string $resource, string $body = ' return $options; } - - protected function uri(?string $endpoint = null): string - { - $account = $this->config->account; - - if (!is_null($this->usingAccountCallback)) { - $account = call_user_func($this->usingAccountCallback, $account); - - $this->usingAccountCallback = null; - } - - if (!is_null($endpoint)) { - [$endpoint, $params] = array_pad(explode('?', $endpoint, 2), 2, ''); - - $endpoint = implode('/', array_map('rawurlencode', explode('/', $endpoint))) . "?{$params}"; - } - - return "{$this->protocol}://{$account}.{$this->baseDomain}/{$endpoint}"; - } } diff --git a/src/Tests/Http/Concerns/HasHttpAssertions.php b/src/Tests/Http/Concerns/HasHttpAssertions.php index 316f1e2..bc36fe2 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()->account); + $value = call_user_func($this->usingAccountCallback, $this->getConfig()->auth->getAccount()); Assert::assertSame($account, $value); return $this; diff --git a/src/Tests/Http/RequestFake.php b/src/Tests/Http/RequestFake.php index 3b86f7b..3570d61 100644 --- a/src/Tests/Http/RequestFake.php +++ b/src/Tests/Http/RequestFake.php @@ -135,4 +135,17 @@ public function options(string $endpoint): Response return $this->fakeResponse ?? new ResponseFake(); } + + public function uri(?string $endpoint = null): string + { + $account = $this->config->auth->getAccount(); + + if (!is_null($endpoint)) { + [$endpoint, $params] = array_pad(explode('?', $endpoint, 2), 2, ''); + + $endpoint = implode('/', array_map('rawurlencode', explode('/', $endpoint))) . "?{$params}"; + } + + return "http://{$account}.microsoft.azure/{$endpoint}"; + } } diff --git a/src/helpers.php b/src/helpers.php index 022dc85..29f9926 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,5 +1,7 @@ setTimezone(new DateTimeZone('GMT'))->format('D, d M Y H:i:s') . ' GMT'; + } +} + +if (!function_exists('convert_to_RFC3339_micro')) { + function convert_to_RFC3339_micro(DateTimeImmutable $dateTime): string + { + $utcDateTime = $dateTime->setTimezone(new DateTimeZone('UTC')); + + $microseconds = $dateTime->format('u'); + $microseconds = str_pad($microseconds, 7, '0', STR_PAD_RIGHT); + + return $utcDateTime->format('Y-m-d\TH:i:s.') . $microseconds . 'Z'; + } +} diff --git a/tests/Feature/Authentication/SharedKeyAuthTest.php b/tests/Feature/Authentication/SharedKeyAuthTest.php index f835a80..5fcbed2 100644 --- a/tests/Feature/Authentication/SharedKeyAuthTest.php +++ b/tests/Feature/Authentication/SharedKeyAuthTest.php @@ -4,7 +4,7 @@ use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; -use Sjpereira\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; +use Sjpereira\AzureStoragePhpSdk\BlobStorage\{Resource}; use Sjpereira\AzureStoragePhpSdk\Contracts\Authentication\Auth; use Sjpereira\AzureStoragePhpSdk\Http\Headers; @@ -16,10 +16,7 @@ }); it('should get date formatted correctly', function () { - $auth = new SharedKeyAuth(new Config([ - 'account' => 'account', - 'key' => base64_encode('key'), - ])); + $auth = new SharedKeyAuth('account', 'key'); expect($auth->getDate()) ->toBe(gmdate('D, d M Y H:i:s T')); @@ -28,10 +25,7 @@ it('should get correctly the authentication signature for all http methods', function (HttpVerb $verb) { $decodedKey = 'my-decoded-account-key'; - $auth = new SharedKeyAuth(new Config([ - 'account' => $account = 'account', - 'key' => base64_encode($decodedKey), - ])); + $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); $headers = new Headers(); $stringToSign = "{$verb->value}\n{$headers->toString()}\n\n/{$account}/"; @@ -53,10 +47,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(new Config([ - 'account' => $account = 'account', - 'key' => base64_encode($decodedKey), - ])); + $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); $verb = HttpVerb::GET; @@ -84,10 +75,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(new Config([ - 'account' => $account = 'account', - 'key' => base64_encode($decodedKey), - ])); + $auth = new SharedKeyAuth($account = 'account', base64_encode($decodedKey)); $verb = HttpVerb::GET; diff --git a/tests/Feature/BlobStorage/BlobStorageConfigTest.php b/tests/Feature/BlobStorage/BlobStorageConfigTest.php index 21191f8..eeaa6a7 100644 --- a/tests/Feature/BlobStorage/BlobStorageConfigTest.php +++ b/tests/Feature/BlobStorage/BlobStorageConfigTest.php @@ -5,23 +5,12 @@ use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; use Sjpereira\AzureStoragePhpSdk\Contracts\Converter; -use Sjpereira\AzureStoragePhpSdk\Exceptions\InvalidArgumentException; use Sjpereira\AzureStoragePhpSdk\Parsers\XmlParser; uses()->group('blob-storage'); -it('should throw an exception if the account isn\'t provided', function () { - expect(new Config(['key' => 'my-account-key'])) // @phpstan-ignore-line - ->toBeInstance(Config::class); -})->throws(InvalidArgumentException::class, 'Account name must be provided.'); - -it('should throw an exception if the key isn\'t provided', function () { - expect(new Config(['account' => 'account'])) // @phpstan-ignore-line - ->toBeInstance(Config::class); -})->throws(InvalidArgumentException::class, 'Account key must be provided.'); - it('should set default config value if none of the optional ones are provided', function () { - expect(new Config(['account' => 'account', 'key' => 'key'])) + expect(new Config(new SharedKeyAuth('account', 'key'))) ->version->toBe(Resource::VERSION) ->parser->toBeInstanceOf(XmlParser::class) ->converter->toBeInstanceOf(Converter::class) diff --git a/tests/Feature/BlobStorage/BlobStorageTest.php b/tests/Feature/BlobStorage/BlobStorageTest.php index 4198fbb..6938d78 100644 --- a/tests/Feature/BlobStorage/BlobStorageTest.php +++ b/tests/Feature/BlobStorage/BlobStorageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Blob\BlobManager; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\{AccountManager, ContainerManager}; use Sjpereira\AzureStoragePhpSdk\BlobStorage\{BlobStorage, Config}; @@ -9,14 +10,8 @@ uses()->group('blob-storage'); -it('should be able to create a new client', function () { - $client = BlobStorage::client(['account' => 'account', 'key' => 'key']); - - expect($client)->toBeInstanceOf(BlobStorage::class); -}); - it('should be able to get blob storage managers', function (string $method, string $class, array $parameters = []) { - $request = new RequestFake(new Config(['account' => 'account', 'key' => 'key'])); + $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); expect(new BlobStorage($request)) ->{$method}(...$parameters)->toBeInstanceOf($class); diff --git a/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php b/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php index 98b27a5..ef5c950 100644 --- a/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php +++ b/tests/Feature/BlobStorage/Managers/BlobStorageAccountManagerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Config; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Entities\Account\{AccountInformation, GeoReplication}; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Managers\Account\{PreflightBlobRequestManager, StoragePropertyManager}; @@ -11,7 +12,7 @@ uses()->group('blob-storage', 'managers', 'accounts'); it('should get account\'s managers', function (string $method, string $class) { - $request = new RequestFake(new Config(['account' => 'account', 'key' => 'key'])); + $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); expect(new AccountManager($request)) ->{$method}()->toBeInstanceOf($class); @@ -21,7 +22,7 @@ ]); it('should get account information', function () { - $request = (new RequestFake(new Config(['account' => 'account', 'key' => 'key']))) + $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) ->withFakeResponse(new ResponseFake(headers: [ 'Server' => ['Server'], 'x-ms-request-id' => ['d5a5d3f6-0000-0000-0000-000000000000'], @@ -57,7 +58,7 @@ XML; - $request = (new RequestFake(new Config(['account' => 'account', 'key' => 'key']))) + $request = (new RequestFake(new Config(new SharedKeyAuth('account', 'key')))) ->withFakeResponse(new ResponseFake($body)); expect((new AccountManager($request))->blobServiceStats(['some' => 'value'])) diff --git a/tests/Feature/Http/RequestTest.php b/tests/Feature/Http/RequestTest.php index bc7370a..985dd23 100644 --- a/tests/Feature/Http/RequestTest.php +++ b/tests/Feature/Http/RequestTest.php @@ -7,6 +7,7 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Assert; use Psr\Http\Message\{RequestInterface, ResponseInterface}; +use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Enums\HttpVerb; use Sjpereira\AzureStoragePhpSdk\BlobStorage\{Config, Resource}; use Sjpereira\AzureStoragePhpSdk\Contracts\Http\Response as HttpResponse; @@ -15,7 +16,7 @@ uses()->group('http'); it('should send get, delete, and options requests', function (string $method, HttpVerb $verb): void { - $config = new Config(['account' => 'my_account', 'key' => 'bar']); + $config = new Config(new SharedKeyAuth('my_account', 'bar')); $request = (new Request($config, $client = new Client())) ->withAuthentication() @@ -40,7 +41,7 @@ ]); it('should send post and put requests', function (string $method, HttpVerb $verb): void { - $config = new Config(['account' => 'my_account', 'key' => 'bar']); + $config = new Config(new SharedKeyAuth('my_account', 'bar')); $request = (new Request($config, $client = new Client())) ->withoutAuthentication() @@ -69,7 +70,7 @@ ]); it('should get request config', function (): void { - $config = new Config(['account' => 'my_account', 'key' => 'bar']); + $config = new Config(new SharedKeyAuth('my_account', 'bar')); expect((new Request($config, new Client()))->getConfig()) ->toBe($config); diff --git a/tests/Unit/Concerns/HasRequestSharedTest.php b/tests/Unit/Concerns/HasRequestSharedTest.php index 0ea0aa6..780929f 100644 --- a/tests/Unit/Concerns/HasRequestSharedTest.php +++ b/tests/Unit/Concerns/HasRequestSharedTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Sjpereira\AzureStoragePhpSdk\Authentication\SharedKeyAuth; use Sjpereira\AzureStoragePhpSdk\BlobStorage\Config; use Sjpereira\AzureStoragePhpSdk\Concerns\HasRequestShared; use Sjpereira\AzureStoragePhpSdk\Contracts\Http\Request; @@ -10,9 +11,8 @@ uses()->group('concerns', 'traits'); it('should have a request shared property', function () { - $request = new RequestFake(new Config(['account' => 'my_account', 'key' => 'bar'])); - - $class = new class ($request) { + $request = new RequestFake(new Config(new SharedKeyAuth('account', 'key'))); + $class = new class ($request) { /** @use HasRequestShared */ use HasRequestShared;