< Summary

Information
Class: Chronicis.Api.Services.AzureBlobMapBlobStore
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/AzureBlobMapBlobStore.cs
Line coverage
100%
Covered lines: 54
Uncovered lines: 0
Coverable lines: 54
Total lines: 198
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%44100%
BuildBasemapBlobKey(...)100%11100%
BuildFeatureGeometryBlobKey(...)100%11100%
GenerateUploadSasUrlAsync(...)100%11100%
GenerateReadSasUrlAsync(...)100%11100%
SanitizeFileName(...)100%22100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/AzureBlobMapBlobStore.cs

#LineLine coverage
 1using Azure.Storage.Blobs;
 2using Azure.Storage.Blobs.Models;
 3using Azure.Storage.Sas;
 4using System.IO.Compression;
 5using System.Text;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Azure Blob Storage service for managing map basemap files.
 11/// </summary>
 12public sealed class AzureBlobMapBlobStore : IMapBlobStore
 13{
 14    private readonly BlobServiceClient _blobServiceClient;
 15    private readonly ILogger<AzureBlobMapBlobStore> _logger;
 16    private const string ContainerName = "chronicis-maps";
 17
 18    public AzureBlobMapBlobStore(
 19        IConfiguration configuration,
 20        ILogger<AzureBlobMapBlobStore> logger)
 21    {
 422        _logger = logger;
 23
 424        var connectionString = configuration["BlobStorage:ConnectionString"]
 425            ?? configuration["BlobStorage__ConnectionString"];
 26
 427        if (string.IsNullOrEmpty(connectionString))
 28        {
 129            _logger.LogErrorSanitized("BlobStorage:ConnectionString not configured.");
 130            throw new InvalidOperationException("BlobStorage:ConnectionString not configured. Please add BlobStorage__Co
 31        }
 32
 33        try
 34        {
 335            _blobServiceClient = new BlobServiceClient(connectionString);
 36
 137            var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 138            containerClient.CreateIfNotExists(PublicAccessType.None);
 39
 140            _logger.LogTraceSanitized("AzureBlobMapBlobStore initialized with container: {ContainerName}", ContainerName
 141        }
 242        catch (Exception ex)
 43        {
 244            _logger.LogErrorSanitized(ex, "Failed to initialize AzureBlobMapBlobStore. Connection string may be invalid.
 245            throw;
 46        }
 147    }
 48
 49    /// <inheritdoc/>
 50    public string BuildBasemapBlobKey(Guid mapId, string fileName)
 51    {
 252        var sanitized = SanitizeFileName(fileName);
 253        return $"maps/{mapId}/basemap/{sanitized}";
 54    }
 55
 56    /// <inheritdoc/>
 57    public string BuildFeatureGeometryBlobKey(Guid mapId, Guid layerId, Guid featureId) =>
 158        $"maps/{mapId}/layers/{layerId}/features/{featureId}.geojson.gz";
 59
 60    /// <inheritdoc/>
 61    public Task<string> GenerateUploadSasUrlAsync(Guid mapId, string fileName, string contentType)
 62    {
 163        var blobKey = BuildBasemapBlobKey(mapId, fileName);
 164        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 165        var blobClient = containerClient.GetBlobClient(blobKey);
 66
 167        var sasBuilder = new BlobSasBuilder
 168        {
 169            BlobContainerName = ContainerName,
 170            BlobName = blobKey,
 171            Resource = "b",
 172            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
 173            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 174        };
 75
 176        sasBuilder.SetPermissions(BlobSasPermissions.Create | BlobSasPermissions.Write);
 77
 178        var sasUrl = blobClient.GenerateSasUri(sasBuilder).ToString();
 79
 180        _logger.LogTraceSanitized("Generated upload SAS URL for blob: {BlobKey}", blobKey);
 81
 182        return Task.FromResult(sasUrl);
 83    }
 84
 85    /// <inheritdoc/>
 86    public Task<string> GenerateReadSasUrlAsync(string blobKey)
 87    {
 188        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 189        var blobClient = containerClient.GetBlobClient(blobKey);
 90
 191        var sasBuilder = new BlobSasBuilder
 192        {
 193            BlobContainerName = ContainerName,
 194            BlobName = blobKey,
 195            Resource = "b",
 196            StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
 197            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15),
 198        };
 99
 1100        sasBuilder.SetPermissions(BlobSasPermissions.Read);
 101
 1102        var sasUrl = blobClient.GenerateSasUri(sasBuilder).ToString();
 103
 1104        _logger.LogTraceSanitized("Generated read SAS URL for blob: {BlobKey}", blobKey);
 105
 1106        return Task.FromResult(sasUrl);
 107    }
 108
 109    /// <inheritdoc/>
 110    public async Task DeleteMapFolderAsync(Guid mapId)
 111    {
 112        var prefix = $"maps/{mapId}/";
 113        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 114        var deletedCount = 0;
 115
 116        await foreach (var blobItem in containerClient.GetBlobsAsync(prefix: prefix))
 117        {
 118            var blobClient = containerClient.GetBlobClient(blobItem.Name);
 119            await blobClient.DeleteIfExistsAsync(DeleteSnapshotsOption.IncludeSnapshots);
 120            deletedCount++;
 121        }
 122
 123        _logger.LogTraceSanitized(
 124            "Deleted {DeletedCount} blob(s) for map folder prefix {Prefix}",
 125            deletedCount,
 126            prefix);
 127    }
 128
 129    /// <inheritdoc/>
 130    public async Task<string> SaveFeatureGeometryAsync(string blobKey, string geometryJson, CancellationToken cancellati
 131    {
 132        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 133        var blobClient = containerClient.GetBlobClient(blobKey);
 134
 135        await using var payloadStream = new MemoryStream();
 136        await using (var gzipStream = new GZipStream(payloadStream, CompressionLevel.SmallestSize, leaveOpen: true))
 137        await using (var writer = new StreamWriter(gzipStream, Encoding.UTF8, 1024, leaveOpen: true))
 138        {
 139            await writer.WriteAsync(geometryJson.AsMemory(), cancellationToken);
 140        }
 141
 142        payloadStream.Position = 0;
 143        var uploadOptions = new BlobUploadOptions
 144        {
 145            HttpHeaders = new BlobHttpHeaders
 146            {
 147                ContentType = "application/json",
 148                ContentEncoding = "gzip",
 149            },
 150        };
 151
 152        var response = await blobClient.UploadAsync(payloadStream, uploadOptions, cancellationToken);
 153        _logger.LogTraceSanitized("Saved geometry blob {BlobKey}", blobKey);
 154        return response.Value.ETag.ToString();
 155    }
 156
 157    /// <inheritdoc/>
 158    public async Task<string?> LoadFeatureGeometryAsync(string blobKey, CancellationToken cancellationToken = default)
 159    {
 160        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 161        var blobClient = containerClient.GetBlobClient(blobKey);
 162
 163        if (!await blobClient.ExistsAsync(cancellationToken))
 164        {
 165            return null;
 166        }
 167
 168        var response = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken);
 169        await using var content = response.Value.Content;
 170        using var gzipStream = new GZipStream(content, CompressionMode.Decompress, leaveOpen: false);
 171        using var reader = new StreamReader(gzipStream, Encoding.UTF8);
 172        return await reader.ReadToEndAsync(cancellationToken);
 173    }
 174
 175    /// <inheritdoc/>
 176    public async Task DeleteFeatureGeometryAsync(string blobKey, CancellationToken cancellationToken = default)
 177    {
 178        var containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
 179        var blobClient = containerClient.GetBlobClient(blobKey);
 180        await blobClient.DeleteIfExistsAsync(DeleteSnapshotsOption.IncludeSnapshots, cancellationToken: cancellationToke
 181        _logger.LogTraceSanitized("Deleted geometry blob {BlobKey}", blobKey);
 182    }
 183
 184    private static string SanitizeFileName(string fileName)
 185    {
 4186        var invalidChars = Path.GetInvalidFileNameChars();
 4187        var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
 188
 4189        if (sanitized.Length > 200)
 190        {
 1191            var extension = Path.GetExtension(sanitized);
 1192            var nameWithoutExt = Path.GetFileNameWithoutExtension(sanitized);
 1193            sanitized = nameWithoutExt[..(200 - extension.Length)] + extension;
 194        }
 195
 4196        return sanitized;
 197    }
 198}