diff --git a/.github/workflows/liquid-ci-cd-adapter-dataverse.yml b/.github/workflows/liquid-ci-cd-adapter-dataverse.yml index 2c2cd2cd..57a36385 100644 --- a/.github/workflows/liquid-ci-cd-adapter-dataverse.yml +++ b/.github/workflows/liquid-ci-cd-adapter-dataverse.yml @@ -1,5 +1,5 @@ # CI & CD workflow -name: CI/CD - Liquid.Cache component for Liquid Application Framework +name: CI/CD - Liquid.Adapter.Dataverse component for Liquid Application Framework on: push: diff --git a/.github/workflows/liquid-ci-cd-adapter-storage.yml b/.github/workflows/liquid-ci-cd-adapter-storage.yml new file mode 100644 index 00000000..15ec4232 --- /dev/null +++ b/.github/workflows/liquid-ci-cd-adapter-storage.yml @@ -0,0 +1,26 @@ +# CI & CD workflow +name: CI/CD - Liquid.Adapter.AzureStorage component for Liquid Application Framework + +on: + push: + branches: [ main ] + paths: + - 'src/Liquid.Adapter.AzureStorage/**' + + pull_request: + branches: [ main, releases/** ] + types: [opened, synchronize, reopened] + paths: + - 'src/Liquid.Adapter.AzureStorage/**' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + call-reusable-build-workflow: + uses: Avanade/Liquid-Application-Framework/.github/workflows/base-liquid-ci-and-cd.yml@main + with: + component_name: Liquid.Adapter.AzureStorage + secrets: + sonar_token: ${{ secrets.SONAR_TOKEN_STORAGE }} + nuget_token: ${{ secrets.PUBLISH_TO_NUGET_ORG }} diff --git a/Liquid.Adapters.sln b/Liquid.Adapters.sln index 5b844ab4..791a31e7 100644 --- a/Liquid.Adapters.sln +++ b/Liquid.Adapters.sln @@ -5,7 +5,15 @@ VisualStudioVersion = 17.6.34202.202 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Liquid.Adapter.Dataverse", "src\Liquid.Adapter.Dataverse\Liquid.Adapter.Dataverse.csproj", "{02191AB8-D13C-4CCC-8455-08813FB0C9C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Liquid.Adapter.Dataverse.Tests", "test\Liquid.Adapter.Dataverse.Tests\Liquid.Adapter.Dataverse.Tests.csproj", "{90D60966-6004-4705-907D-780503EBC141}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Liquid.Adapter.Dataverse.Tests", "test\Liquid.Adapter.Dataverse.Tests\Liquid.Adapter.Dataverse.Tests.csproj", "{90D60966-6004-4705-907D-780503EBC141}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dataverse", "Dataverse", "{0E973865-5B87-43F2-B513-CD1DA96A2A3A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureStorage", "AzureStorage", "{81CB75D9-FC31-4533-9A2D-C9277DD4A33E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Liquid.Adapter.AzureStorage", "src\Liquid.Adapter.AzureStorage\Liquid.Adapter.AzureStorage.csproj", "{E21AF05A-738E-4DA2-AEEE-9900D7534F7C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Liquid.Adapter.AzureStorage.Tests", "test\Liquid.Adapter.AzureStorage.Tests\Liquid.Adapter.AzureStorage.Tests.csproj", "{A2A7E164-98DF-4953-9679-B35E109E8990}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -21,10 +29,24 @@ Global {90D60966-6004-4705-907D-780503EBC141}.Debug|Any CPU.Build.0 = Debug|Any CPU {90D60966-6004-4705-907D-780503EBC141}.Release|Any CPU.ActiveCfg = Release|Any CPU {90D60966-6004-4705-907D-780503EBC141}.Release|Any CPU.Build.0 = Release|Any CPU + {E21AF05A-738E-4DA2-AEEE-9900D7534F7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E21AF05A-738E-4DA2-AEEE-9900D7534F7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E21AF05A-738E-4DA2-AEEE-9900D7534F7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E21AF05A-738E-4DA2-AEEE-9900D7534F7C}.Release|Any CPU.Build.0 = Release|Any CPU + {A2A7E164-98DF-4953-9679-B35E109E8990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2A7E164-98DF-4953-9679-B35E109E8990}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2A7E164-98DF-4953-9679-B35E109E8990}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2A7E164-98DF-4953-9679-B35E109E8990}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02191AB8-D13C-4CCC-8455-08813FB0C9C3} = {0E973865-5B87-43F2-B513-CD1DA96A2A3A} + {90D60966-6004-4705-907D-780503EBC141} = {0E973865-5B87-43F2-B513-CD1DA96A2A3A} + {E21AF05A-738E-4DA2-AEEE-9900D7534F7C} = {81CB75D9-FC31-4533-9A2D-C9277DD4A33E} + {A2A7E164-98DF-4953-9679-B35E109E8990} = {81CB75D9-FC31-4533-9A2D-C9277DD4A33E} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8105640B-39D2-4FFE-8BBD-D8E37F2A070D} EndGlobalSection diff --git a/src/Liquid.Adapter.AzureStorage/BlobClientFactory.cs b/src/Liquid.Adapter.AzureStorage/BlobClientFactory.cs new file mode 100644 index 00000000..51b58eb7 --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/BlobClientFactory.cs @@ -0,0 +1,56 @@ +using Azure.Core; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Options; + +namespace Liquid.Adapter.AzureStorage +{ + /// + public class BlobClientFactory : IBlobClientFactory + { + private readonly StorageSettings _options; + private IList _clients = new List(); + + /// + public IList Clients => _clients; + + /// + /// Inicialize a new instance of + /// + /// Configurations set. + /// + public BlobClientFactory(IOptions? options) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public List SetContainerClients() + { + if(_options.Containers.Count == 0) + throw new ArgumentNullException(nameof(_options)); + + var clients = new List(); + + foreach(var container in _options.Containers) + { + var client = new BlobContainerClient(container.ConnectionString,container.ContainerName); + + clients.Add(client); + } + + return clients; + } + + /// + public BlobContainerClient GetContainerClient(string containerName) + { + var client = _clients.FirstOrDefault(x => x.Name == containerName); + + if (client == null) { + throw new ArgumentException(nameof(containerName)); + } + + return client; + } + } +} diff --git a/src/Liquid.Adapter.AzureStorage/BlobStorageAdapter.cs b/src/Liquid.Adapter.AzureStorage/BlobStorageAdapter.cs new file mode 100644 index 00000000..19531a1a --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/BlobStorageAdapter.cs @@ -0,0 +1,166 @@ +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Azure.Storage.Sas; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Liquid.Adapter.AzureStorage +{ + /// + [ExcludeFromCodeCoverage] + public class BlobStorageAdapter : ILiquidBlobStorageAdapter + { + private readonly IBlobClientFactory _factory; + + /// + /// Initialize a new instance of + /// + /// + /// + public BlobStorageAdapter(IBlobClientFactory factory) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + + _factory.SetContainerClients(); + } + + /// + public async Task DeleteByTags(IDictionary tags, string containerName) + { + var client = _factory.GetContainerClient(containerName); + + var stringFilter = string.Empty; + + foreach (var tag in tags) + { + stringFilter += @$"""{tag.Key}"" = '{tag.Value}' AND "; + } + + stringFilter = stringFilter.Substring(0, stringFilter.Length - 4); + + await foreach (TaggedBlobItem blobItem in client.FindBlobsByTagsAsync(stringFilter)) + { + var blockBlob = client.GetBlockBlobClient(blobItem.BlobName); + + await blockBlob.DeleteAsync(); + }; + } + + /// + public async Task> GetAllBlobs(string containerName) + { + var client = _factory.GetContainerClient(containerName); + + var results = new List(); + + await foreach (var blobItem in client.GetBlobsAsync()) + { + var blockBlob = client.GetBlockBlobClient(blobItem.Name); + var blob = await blockBlob.DownloadContentAsync(); + + var item = new LiquidBlob + { + Blob = Encoding.UTF8.GetString(blob.Value.Content.ToArray()), + Name = blobItem.Name + }; + results.Add(item); + } + + return results; + } + + /// + public async Task Delete(string id, string containerName) + { + var client = _factory.GetContainerClient(containerName); + + var blobClient = client.GetBlobClient(id); + + await blobClient.DeleteAsync(); + } + + /// + public async Task> ReadBlobsByTags(IDictionary tags, string containerName) + { + var client = _factory.GetContainerClient(containerName); + + var stringFilter = string.Empty; + foreach (var tag in tags) + { + stringFilter += @$"""{tag.Key}"" = '{tag.Value}' AND "; + } + stringFilter = stringFilter.Substring(0, stringFilter.Length - 4); + + var results = new List(); + await foreach (TaggedBlobItem blobItem in client.FindBlobsByTagsAsync(stringFilter)) + { + var blockBlob = client.GetBlockBlobClient(blobItem.BlobName); + var blob = await blockBlob.DownloadContentAsync(); + var item = new LiquidBlob + { + Blob = Encoding.UTF8.GetString(blob.Value.Content.ToArray()), + Tags = blockBlob.GetTags().Value.Tags, + Name = blobItem.BlobName + }; + results.Add(item); + } + return results; + } + + /// + public async Task UploadBlob(string data, string name, string containerName, IDictionary? tags = null) + { + var client = _factory.GetContainerClient(containerName); + + var blockBlob = client.GetBlockBlobClient(name); + + var options = new BlobUploadOptions() + { + Tags = tags + }; + await blockBlob.UploadAsync(new MemoryStream(Encoding.UTF8.GetBytes(data)), options); + } + + /// + public async Task ReadBlobsByName(string blobName, string containerName) + { + var client = _factory.GetContainerClient(containerName); + + var blockBlob = client.GetBlockBlobClient(blobName); + var blob = await blockBlob.DownloadContentAsync(); + var item = new LiquidBlob + { + Blob = Encoding.UTF8.GetString(blob.Value.Content.ToArray()), + Tags = blockBlob.GetTags().Value.Tags, + Name = blobName + }; + + return item; + } + + /// + public string? GetBlobSasUri(string blobName, string containerName, DateTimeOffset expiresOn, BlobContainerSasPermissions permissions) + { + var blobClient = _factory.GetContainerClient(containerName); + + if (!blobClient.CanGenerateSasUri) + { + return null; + } + + var sasBuilder = new BlobSasBuilder() + { + BlobContainerName = blobClient.Name, + BlobName = blobName, + Resource = "b" + }; + + sasBuilder.ExpiresOn = expiresOn; + sasBuilder.SetPermissions(permissions); + + var sasURI = blobClient.GenerateSasUri(sasBuilder); + + return sasURI.AbsolutePath; + } + } +} diff --git a/src/Liquid.Adapter.AzureStorage/Extensions/IServiceCollectionExtensions.cs b/src/Liquid.Adapter.AzureStorage/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 00000000..0ed6723f --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; + +namespace Liquid.Adapter.AzureStorage.Extensions +{ + /// + /// Extension methods of + /// for register Liquid Azure Storage services. + /// + [ExcludeFromCodeCoverage] + public static class IServiceCollectionExtensions + { + /// + /// Registers service, it's dependency + /// , and also set configuration + /// option . + /// + /// service collection instance. + /// configuration section of storage settings. + public static IServiceCollection AddLiquidAzureStorageAdapter(this IServiceCollection services, string configSection) + { + services.AddOptions() + .Configure((settings, configuration) => + { + configuration.GetSection(configSection).Bind(settings); + }); + + services.AddTransient(); + + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/Liquid.Adapter.AzureStorage/IBlobClientFactory.cs b/src/Liquid.Adapter.AzureStorage/IBlobClientFactory.cs new file mode 100644 index 00000000..6c977af2 --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/IBlobClientFactory.cs @@ -0,0 +1,30 @@ +using Azure.Storage.Blobs; + +namespace Liquid.Adapter.AzureStorage +{ + /// + /// instances factory. + /// + public interface IBlobClientFactory + { + /// + /// List of instances of . + /// + IList Clients { get; } + + /// + /// Initialize an instance of + /// for each container on the and + /// add to . + /// + List SetContainerClients(); + + /// + /// Get an instance of + /// by name. + /// + /// + /// + BlobContainerClient GetContainerClient(string containerName); + } +} diff --git a/src/Liquid.Adapter.AzureStorage/ILiquidBlobStorageAdapter.cs b/src/Liquid.Adapter.AzureStorage/ILiquidBlobStorageAdapter.cs new file mode 100644 index 00000000..a6fe25cf --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/ILiquidBlobStorageAdapter.cs @@ -0,0 +1,71 @@ +using Azure.Storage.Sas; + +namespace Liquid.Adapter.AzureStorage +{ + /// + /// Definition of BlobStorage integration service. + /// + public interface ILiquidBlobStorageAdapter + { + /// + /// Upload a specific blob. + /// + /// Blob content. + /// Blob path. + /// Blob container name. + /// Blob list of tags. + Task UploadBlob(string data, string name, string containerName, IDictionary? tags = null); + + /// + /// Remove blob by id. + /// + /// blob name. + /// Blob container name. + Task Delete(string id, string containerName); + + /// + /// Filter blob by tags and remove them. + /// + /// Tags for filter. + /// Blob container name. + Task DeleteByTags(IDictionary tags, string containerName); + + /// + /// Get all blobs from a container. + /// + /// Blob container name. + /// List of . + Task> GetAllBlobs(string containerName); + + /// + /// Filter blobs by tags. + /// + /// Tags for filter. + /// Blob container name. + /// List of . + Task> ReadBlobsByTags(IDictionary tags, string containerName); + + /// + /// Dowload a specific blob. + /// + /// Blob Id. + /// Blob container name. + /// . + Task ReadBlobsByName(string blobName, string containerName); + + /// + /// generates a Blob Shared Access Signature (SAS) Uri + /// based on the parameters passed. The SAS is signed by the shared key + /// credential of the client. + /// + /// The id of the blob. + /// Name of the container where the blob is stored. + /// The time at which the shared access signature becomes invalid. + /// This field must be omitted if it has been specified in an + /// associated stored access policy. + /// The permissions associated with the shared access signature. The + /// user is restricted to operations allowed by the permissions. + /// Blob sas uri absolute path. + string? GetBlobSasUri(string blobName, string containerName, DateTimeOffset expiresOn, BlobContainerSasPermissions permissions); + } +} diff --git a/src/Liquid.Adapter.AzureStorage/Liquid.Adapter.AzureStorage.csproj b/src/Liquid.Adapter.AzureStorage/Liquid.Adapter.AzureStorage.csproj new file mode 100644 index 00000000..c3d8f88b --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/Liquid.Adapter.AzureStorage.csproj @@ -0,0 +1,36 @@ + + + + net6.0 + enable + enable + Avanade Brazil + Avanade Inc. + Liquid - Modern Application Framework + Avanade 2019 + 6.0.0-preview-20221201-01 + true + true + + Adapter for Microsoft Azure Storage integrations. + This component is part of Liquid Application Framework. + + logo.png + https://github.com/Avanade/Liquid-Application-Framework + + + + + True + \ + + + + + + + + + + + diff --git a/src/Liquid.Adapter.AzureStorage/LiquidBlob.cs b/src/Liquid.Adapter.AzureStorage/LiquidBlob.cs new file mode 100644 index 00000000..84e5c0cc --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/LiquidBlob.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Liquid.Adapter.AzureStorage +{ + /// + /// Set de propriedades referentes à um item do BlobStorage. + /// + [ExcludeFromCodeCoverage] + public class LiquidBlob + { + /// + /// Lista de tags referentes ao blob. + /// + public IDictionary? Tags { get; set; } + + /// + /// Conteúdo do blob. + /// + public string? Blob { get; set; } + + /// + /// Nome do arquivo no Storage. + /// + public string Name { get; set; } + } +} diff --git a/src/Liquid.Adapter.AzureStorage/StorageSettings.cs b/src/Liquid.Adapter.AzureStorage/StorageSettings.cs new file mode 100644 index 00000000..73ccd0c3 --- /dev/null +++ b/src/Liquid.Adapter.AzureStorage/StorageSettings.cs @@ -0,0 +1,32 @@ +namespace Liquid.Adapter.AzureStorage +{ + /// + /// Set of Azure Storage containers configs. + /// + public class StorageSettings + { + /// + /// List of container settings. + /// + public List Containers { get; set; } = new List(); + } + + /// + /// Set of a container connection configuration. + /// + public class ContainerSettings + { + /// + /// A connection string includes the authentication information + /// required for your application to access data in an Azure Storage + /// account at runtime. + /// + public string ConnectionString { get; set; } + + /// + /// The name of the blob container in the storage account to reference. + /// + public string ContainerName { get; set; } + + } +} diff --git a/test/Liquid.Adapter.AzureStorage.Tests/BlobClientFactoryTests.cs b/test/Liquid.Adapter.AzureStorage.Tests/BlobClientFactoryTests.cs new file mode 100644 index 00000000..8da4b530 --- /dev/null +++ b/test/Liquid.Adapter.AzureStorage.Tests/BlobClientFactoryTests.cs @@ -0,0 +1,67 @@ +using Azure.Storage.Blobs; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Liquid.Adapter.AzureStorage.Tests +{ + public class BlobClientFactoryTests + { + + private readonly IBlobClientFactory _sut; + private readonly IOptions _options; + + public BlobClientFactoryTests() + { + _options = Substitute.For>(); + + var settings = new StorageSettings(); + settings.Containers.Add(new ContainerSettings() + { + ContainerName = "test", + ConnectionString = "testestestes" + }); + + _options.Value.ReturnsForAnyArgs(settings); + _sut = new BlobClientFactory(_options); + } + + + [Fact] + public void Ctor_WhenOptionsIsNull_ThenReturnArgumentNullException() + { + Assert.Throws(() => new BlobClientFactory(null)); + } + + [Fact] + public void Ctor_WhenOptionsExists_ThenBlobClientFactoryInstance() + { + var result = new BlobClientFactory(_options); + Assert.NotNull(result); + Assert.IsType(result); + } + [Fact] + public void SetContainerClients_WhenOptionsNotSet_ThenThrowArgumentNullException() + { + var options = Substitute.For>(); + options.Value.ReturnsForAnyArgs(new StorageSettings()); + + var sut = new BlobClientFactory(options); + + Assert.Throws(() => sut.SetContainerClients()) ; + + } + + [Fact] + public void SetContainerClients_WhenContainerNameIsInvalid_ThenThrowFormatException() + { + Assert.True(_sut.Clients.Count == 0); + Assert.Throws(() => _sut.SetContainerClients()); + } + + [Fact] + public void GetContainerClient_WhenClientDoesntExists_ThenThrowArgumentException() + { + Assert.Throws(() => _sut.GetContainerClient("test")); + } + } +} \ No newline at end of file diff --git a/test/Liquid.Adapter.AzureStorage.Tests/Liquid.Adapter.AzureStorage.Tests.csproj b/test/Liquid.Adapter.AzureStorage.Tests/Liquid.Adapter.AzureStorage.Tests.csproj new file mode 100644 index 00000000..d51aa7de --- /dev/null +++ b/test/Liquid.Adapter.AzureStorage.Tests/Liquid.Adapter.AzureStorage.Tests.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Liquid.Adapter.AzureStorage.Tests/Usings.cs b/test/Liquid.Adapter.AzureStorage.Tests/Usings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/test/Liquid.Adapter.AzureStorage.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file