|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.IO; |
| 4 | +using System.Linq; |
| 5 | +using System.Threading; |
| 6 | +using System.Threading.Tasks; |
| 7 | +using Azure; |
| 8 | +using Azure.Storage.Blobs; |
| 9 | +using Azure.Storage.Blobs.Models; |
| 10 | +using Azure.Storage.Blobs.Specialized; |
| 11 | +using ManagedCode.Storage.Core.Objects; |
| 12 | + |
| 13 | +namespace ManagedCode.Storage.Azure; |
| 14 | + |
| 15 | +internal sealed class AzureObjectOperations(BlobContainerClient container) : IMultipartObjectStorage |
| 16 | +{ |
| 17 | + public Uri ContainerUri => container.Uri; |
| 18 | + |
| 19 | + public Task<StorageContainerInfo> GetContainerInfoAsync(CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 20 | + { |
| 21 | + var value = (await container.GetPropertiesAsync(cancellationToken: cancellationToken)).Value; |
| 22 | + return new StorageContainerInfo(value.ETag.ToString(), value.PublicAccess == PublicAccessType.None, |
| 23 | + new Dictionary<string, string>(value.Metadata)); |
| 24 | + }); |
| 25 | + |
| 26 | + public Task CreatePrivateContainerAsync(IReadOnlyDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 27 | + { |
| 28 | + await container.CreateIfNotExistsAsync(PublicAccessType.None, Copy(metadata), cancellationToken: cancellationToken); |
| 29 | + return true; |
| 30 | + }); |
| 31 | + |
| 32 | + public Task SetContainerMetadataAsync(IReadOnlyDictionary<string, string> metadata, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 33 | + { |
| 34 | + await container.SetMetadataAsync(Copy(metadata), cancellationToken: cancellationToken); |
| 35 | + return true; |
| 36 | + }); |
| 37 | + |
| 38 | + public Task<bool> DeleteContainerIfExistsAsync(CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 39 | + (await container.DeleteIfExistsAsync(cancellationToken: cancellationToken)).Value); |
| 40 | + |
| 41 | + public Task<StorageObjectInfo> GetObjectInfoAsync(string path, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 42 | + Info(path, (await container.GetBlobClient(path).GetPropertiesAsync(cancellationToken: cancellationToken)).Value)); |
| 43 | + |
| 44 | + public Task<bool> ObjectExistsAsync(string path, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 45 | + (await container.GetBlobClient(path).ExistsAsync(cancellationToken)).Value); |
| 46 | + |
| 47 | + public Task<Stream> OpenObjectReadAsync(string path, StorageReadOptions? options = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 48 | + { |
| 49 | + options ??= new StorageReadOptions(); |
| 50 | + ArgumentOutOfRangeException.ThrowIfNegative(options.Offset); |
| 51 | + if (options.Length is <= 0) throw new ArgumentOutOfRangeException(nameof(options)); |
| 52 | + var blob = container.GetBlobClient(path); |
| 53 | + var etag = options.IfMatch ?? (await blob.GetPropertiesAsync(cancellationToken: cancellationToken)).Value.ETag.ToString(); |
| 54 | + if (options.Length is null) |
| 55 | + { |
| 56 | + return await blob.OpenReadAsync(new BlobOpenReadOptions(false) |
| 57 | + { |
| 58 | + Position = options.Offset, |
| 59 | + Conditions = new BlobRequestConditions { IfMatch = new ETag(etag) } |
| 60 | + }, cancellationToken); |
| 61 | + } |
| 62 | + var download = await blob.DownloadStreamingAsync(new BlobDownloadOptions |
| 63 | + { |
| 64 | + Range = new HttpRange(options.Offset, options.Length), |
| 65 | + Conditions = new BlobRequestConditions { IfMatch = new ETag(etag) } |
| 66 | + }, cancellationToken); |
| 67 | + return download.Value.Content; |
| 68 | + }); |
| 69 | + |
| 70 | + public Task<StorageObjectInfo> WriteObjectAsync(string path, Stream content, StorageWriteOptions? options = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 71 | + { |
| 72 | + options ??= new StorageWriteOptions(); |
| 73 | + var response = await container.GetBlobClient(path).UploadAsync(content, new BlobUploadOptions |
| 74 | + { |
| 75 | + Conditions = Conditions(options), |
| 76 | + HttpHeaders = Headers(options), |
| 77 | + Metadata = Copy(options.Metadata), |
| 78 | + TransferOptions = new global::Azure.Storage.StorageTransferOptions { MaximumConcurrency = 1, InitialTransferSize = 4 * 1024 * 1024, MaximumTransferSize = 4 * 1024 * 1024 } |
| 79 | + }, cancellationToken); |
| 80 | + return await ReadWrittenInfoAsync(path, response.Value.ETag, cancellationToken); |
| 81 | + }); |
| 82 | + |
| 83 | + public Task SetObjectMetadataAsync(string path, IReadOnlyDictionary<string, string> metadata, string? ifMatch = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 84 | + { |
| 85 | + await container.GetBlobClient(path).SetMetadataAsync(Copy(metadata), new BlobRequestConditions { IfMatch = ETagOrNull(ifMatch) }, cancellationToken); |
| 86 | + return true; |
| 87 | + }); |
| 88 | + |
| 89 | + public Task<bool> DeleteObjectIfExistsAsync(string path, bool includeSnapshots = false, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 90 | + (await container.GetBlobClient(path).DeleteIfExistsAsync(includeSnapshots ? DeleteSnapshotsOption.IncludeSnapshots : DeleteSnapshotsOption.None, |
| 91 | + cancellationToken: cancellationToken)).Value); |
| 92 | + |
| 93 | + public Task<StorageObjectPage> ListObjectsAsync(string? prefix = null, string? continuationToken = null, int pageSize = 100, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 94 | + { |
| 95 | + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1); |
| 96 | + ArgumentOutOfRangeException.ThrowIfGreaterThan(pageSize, 5000); |
| 97 | + await foreach (var page in container.GetBlobsAsync(new GetBlobsOptions { Prefix = prefix, Traits = BlobTraits.Metadata }, cancellationToken) |
| 98 | + .AsPages(continuationToken, pageSize)) |
| 99 | + { |
| 100 | + return new StorageObjectPage(page.Values.Select(item => new StorageObjectInfo(item.Name, |
| 101 | + item.Properties.ETag?.ToString() ?? throw new InvalidDataException("Object listing returned no ETag."), |
| 102 | + item.Properties.ContentLength ?? throw new InvalidDataException("Object listing returned no length."), |
| 103 | + item.Properties.ContentType, item.Properties.ContentEncoding, new Dictionary<string, string>(item.Metadata), item.Properties.LastModified)).ToArray(), page.ContinuationToken); |
| 104 | + } |
| 105 | + return new StorageObjectPage([], null); |
| 106 | + }); |
| 107 | + |
| 108 | + public Task StagePartAsync(string path, string partId, Stream content, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 109 | + { |
| 110 | + await container.GetBlockBlobClient(path).StageBlockAsync(partId, content, cancellationToken: cancellationToken); |
| 111 | + return true; |
| 112 | + }); |
| 113 | + |
| 114 | + public Task<StorageObjectInfo> CommitPartsAsync(string path, IReadOnlyList<string> partIds, StorageWriteOptions? options = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () => |
| 115 | + { |
| 116 | + options ??= new StorageWriteOptions(); |
| 117 | + var response = await container.GetBlockBlobClient(path).CommitBlockListAsync(partIds, new CommitBlockListOptions |
| 118 | + { |
| 119 | + Conditions = Conditions(options), |
| 120 | + HttpHeaders = Headers(options), |
| 121 | + Metadata = Copy(options.Metadata) |
| 122 | + }, cancellationToken); |
| 123 | + return await ReadWrittenInfoAsync(path, response.Value.ETag, cancellationToken); |
| 124 | + }); |
| 125 | + |
| 126 | + private async Task<StorageObjectInfo> ReadWrittenInfoAsync(string path, ETag etag, CancellationToken cancellationToken) => |
| 127 | + Info(path, (await container.GetBlobClient(path).GetPropertiesAsync(new BlobRequestConditions { IfMatch = etag }, cancellationToken)).Value); |
| 128 | + |
| 129 | + private static StorageObjectInfo Info(string path, BlobProperties value) => new(path, value.ETag.ToString(), value.ContentLength, |
| 130 | + value.ContentType, value.ContentEncoding, new Dictionary<string, string>(value.Metadata), value.LastModified); |
| 131 | + |
| 132 | + private static BlobRequestConditions Conditions(StorageWriteOptions options) |
| 133 | + { |
| 134 | + if (options.IfAbsent && options.IfMatch is not null) throw new ArgumentException("IfAbsent and IfMatch are mutually exclusive."); |
| 135 | + return new BlobRequestConditions { IfMatch = ETagOrNull(options.IfMatch), IfNoneMatch = options.IfAbsent ? ETag.All : null }; |
| 136 | + } |
| 137 | + |
| 138 | + private static BlobHttpHeaders Headers(StorageWriteOptions options) => new() { ContentType = options.ContentType, ContentEncoding = options.ContentEncoding }; |
| 139 | + private static ETag? ETagOrNull(string? value) => value is null ? null : new ETag(value); |
| 140 | + private static Dictionary<string, string>? Copy(IReadOnlyDictionary<string, string>? value) => value is null ? null : new(value); |
| 141 | + |
| 142 | + private static async Task<T> ExecuteAsync<T>(Func<Task<T>> operation) |
| 143 | + { |
| 144 | + try { return await operation(); } |
| 145 | + catch (RequestFailedException exception) |
| 146 | + { |
| 147 | + throw new StorageOperationException("Object storage operation failed.", exception.Status, exception, exception.ErrorCode); |
| 148 | + } |
| 149 | + } |
| 150 | +} |
0 commit comments