From a9fda5ca3f86b4731f89c363951e17b29cf8f5fb Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Thu, 3 Sep 2026 18:39:07 +0200 Subject: [PATCH 1/3] DeleteTable init --- .github/workflows/DeleteTable_release.yml | 16 +++ .../workflows/DeleteTable_test_on_main.yml | 33 +++++ .../workflows/DeleteTable_test_on_push.yml | 34 +++++ .../CHANGELOG.md | 10 ++ .../.env.example | 14 ++ .../ErrorHandlerTest.cs | 121 ++++++++++++++++ ...AzureTableStorage.DeleteTable.Tests.csproj | 24 ++++ .../FunctionalTests.cs | 111 +++++++++++++++ .../GlobalSuppressions.cs | 8 ++ .../TestBase.cs | 69 ++++++++++ .../Frends.AzureTableStorage.DeleteTable.sln | 40 ++++++ .../Attributes/RequiredIfAttribute.cs | 34 +++++ .../Definitions/Connection.cs | 130 ++++++++++++++++++ .../Definitions/Enums.cs | 32 +++++ .../Definitions/Error.cs | 21 +++ .../Definitions/Input.cs | 19 +++ .../Definitions/Options.cs | 34 +++++ .../Definitions/Result.cs | 37 +++++ .../Frends.AzureTableStorage.DeleteTable.cs | 105 ++++++++++++++ ...rends.AzureTableStorage.DeleteTable.csproj | 44 ++++++ .../FrendsTaskMetadata.json | 7 + .../GlobalSuppressions.cs | 10 ++ .../Helpers/ConnectionHandler.cs | 115 ++++++++++++++++ .../Helpers/ErrorHandler.cs | 56 ++++++++ .../Helpers/ValidationHandler.cs | 34 +++++ .../migration.json | 12 ++ .../README.md | 34 +++++ 27 files changed, 1204 insertions(+) create mode 100644 .github/workflows/DeleteTable_release.yml create mode 100644 .github/workflows/DeleteTable_test_on_main.yml create mode 100644 .github/workflows/DeleteTable_test_on_push.yml create mode 100644 Frends.AzureTableStorage.DeleteTable/CHANGELOG.md create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/.env.example create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/ErrorHandlerTest.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/Frends.AzureTableStorage.DeleteTable.Tests.csproj create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/GlobalSuppressions.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/TestBase.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.sln create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Attributes/RequiredIfAttribute.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Connection.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Enums.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Error.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Input.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Options.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Result.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.csproj create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/FrendsTaskMetadata.json create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/GlobalSuppressions.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ConnectionHandler.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ErrorHandler.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ValidationHandler.cs create mode 100644 Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/migration.json create mode 100644 Frends.AzureTableStorage.DeleteTable/README.md diff --git a/.github/workflows/DeleteTable_release.yml b/.github/workflows/DeleteTable_release.yml new file mode 100644 index 0000000..ccb0c8b --- /dev/null +++ b/.github/workflows/DeleteTable_release.yml @@ -0,0 +1,16 @@ +name: DeleteTable_release +permissions: + contents: write + +on: + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/release.yml@main + with: + workdir: Frends.AzureTableStorage.DeleteTable + dotnet_version: 8.0.x + strict_analyzers: true + secrets: + feed_api_key: ${{ secrets.TASKS_FEED_API_KEY }} diff --git a/.github/workflows/DeleteTable_test_on_main.yml b/.github/workflows/DeleteTable_test_on_main.yml new file mode 100644 index 0000000..8ca106c --- /dev/null +++ b/.github/workflows/DeleteTable_test_on_main.yml @@ -0,0 +1,33 @@ +name: DeleteTable_test_on_main +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - 'Frends.AzureTableStorage.DeleteTable/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + with: + workdir: Frends.AzureTableStorage.DeleteTable + dotnet_version: 8.0.x + strict_analyzers: true + env_var_name_1: Frends_AzureTableStorage_ConnString + env_var_name_2: Frends_AzureTableStorage_AccountName + env_var_name_3: Frends_AzureTableStorage_TenantID + env_var_name_4: Frends_AzureTableStorage_ClientID + env_var_name_5: Frends_AzureTableStorage_ClientSecret + env_var_name_6: Frends_AzureTableStorage_SasToken + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + env_var_value_1: ${{ secrets.FRENDS_AZURETABLESTORAGE_CONNSTRING }} + env_var_value_2: ${{ secrets.FRENDS_AZURETABLESTORAGE_ACCOUNTNAME }} + env_var_value_3: ${{ secrets.FRENDS_AZURETABLESTORAGE_TENANTID }} + env_var_value_4: ${{ secrets.FRENDS_AZURETABLESTORAGE_CLIENTID }} + env_var_value_5: ${{ secrets.FRENDS_AZURETABLESTORAGE_CLIENTSECRET }} + env_var_value_6: ${{ secrets.FRENDS_AZURETABLESTORAGE_SASTOKEN }} diff --git a/.github/workflows/DeleteTable_test_on_push.yml b/.github/workflows/DeleteTable_test_on_push.yml new file mode 100644 index 0000000..0ef1905 --- /dev/null +++ b/.github/workflows/DeleteTable_test_on_push.yml @@ -0,0 +1,34 @@ +name: DeleteTable_test_on_push +permissions: + contents: read + +on: + push: + branches-ignore: + - main + paths: + - 'Frends.AzureTableStorage.DeleteTable/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + with: + workdir: Frends.AzureTableStorage.DeleteTable + dotnet_version: 8.0.x + strict_analyzers: true + env_var_name_1: Frends_AzureTableStorage_ConnString + env_var_name_2: Frends_AzureTableStorage_AccountName + env_var_name_3: Frends_AzureTableStorage_TenantID + env_var_name_4: Frends_AzureTableStorage_ClientID + env_var_name_5: Frends_AzureTableStorage_ClientSecret + env_var_name_6: Frends_AzureTableStorage_SasToken + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} + env_var_value_1: ${{ secrets.FRENDS_AZURETABLESTORAGE_CONNSTRING }} + env_var_value_2: ${{ secrets.FRENDS_AZURETABLESTORAGE_ACCOUNTNAME }} + env_var_value_3: ${{ secrets.FRENDS_AZURETABLESTORAGE_TENANTID }} + env_var_value_4: ${{ secrets.FRENDS_AZURETABLESTORAGE_CLIENTID }} + env_var_value_5: ${{ secrets.FRENDS_AZURETABLESTORAGE_CLIENTSECRET }} + env_var_value_6: ${{ secrets.FRENDS_AZURETABLESTORAGE_SASTOKEN }} diff --git a/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md b/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md new file mode 100644 index 0000000..d7fe46f --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## [1.0.0] - 2026-09-01 + +### Added + +- Initial implementation of DeleteTable task for Azure Table Storage +- Support for ConnectionString, OAuth2, SasToken, ArcManagedIdentity, and ArcManagedIdentityCrossTenant authentication +- FailIfTableNotExists option to control behavior when table doesn't exist +- Comprehensive test coverage including functional and error handling tests diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/.env.example b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/.env.example new file mode 100644 index 0000000..0a06714 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/.env.example @@ -0,0 +1,14 @@ +# Rename this file to .env to run tests locally + +# Connection String authentication (recommended for local testing) +Frends_AzureTableStorage_ConnString="DefaultEndpointsProtocol=https;AccountName=youraccount;AccountKey=yourkey;EndpointSuffix=core.windows.net" + +# OAuth2 authentication +Frends_AzureTableStorage_AccountName="youraccount" +Frends_AzureTableStorage_TenantID="12345678-1234-1234-1234-123456789012" +Frends_AzureTableStorage_ClientID="12345678-1234-1234-1234-123456789012" +Frends_AzureTableStorage_ClientSecret="your-client-secret" + +# SAS Token authentication +Frends_AzureTableStorage_AccountName="youraccount" +Frends_AzureTableStorage_SasToken="?sv=2021-06-08&ss=t&srt=sco&sp=rwdlacu&se=2024-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=..." diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/ErrorHandlerTest.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/ErrorHandlerTest.cs new file mode 100644 index 0000000..2cd352f --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/ErrorHandlerTest.cs @@ -0,0 +1,121 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; +using Frends.AzureTableStorage.DeleteTable.Definitions; +using NUnit.Framework; + +namespace Frends.AzureTableStorage.DeleteTable.Tests; + +[TestFixture] +internal class ErrorHandlerTest : TestBase +{ + private const string CustomErrorMessage = "Custom error occurred during table creation"; + + [Test] + public void Should_Throw_Error_When_ThrowErrorOnFailure_Is_True() + { + var input = new Input { TableName = "TestTable" }; + var connection = new Connection + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = "Invalid", + }; + var options = DefaultOptions(); + options.ThrowErrorOnFailure = true; + + Func action = async () => { await AzureTableStorage.DeleteTable(input, connection, options, CancellationToken.None); }; + var ex = Assert.ThrowsAsync(action); + Assert.That(ex, Is.Not.Null); + } + + [Test] + public async Task Should_Return_Failed_Result_When_ThrowErrorOnFailure_Is_False() + { + var input = new Input { TableName = "TestTable" }; + var connection = new Connection + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = "Invalid", + }; + var options = DefaultOptions(); + options.ThrowErrorOnFailure = false; + + var result = await AzureTableStorage.DeleteTable(input, connection, options, CancellationToken.None); + + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Is.Not.Null); + Assert.That(result.Error.Message, Is.Not.Empty); + } + + [Test] + public void Should_Use_Custom_ErrorMessageOnFailure() + { + var input = new Input { TableName = "TestTable" }; + var connection = new Connection + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = "Invalid", + }; + var options = DefaultOptions(); + options.ErrorMessageOnFailure = CustomErrorMessage; + options.ThrowErrorOnFailure = true; + + Func action = async () => { await AzureTableStorage.DeleteTable(input, connection, options, CancellationToken.None); }; + var ex = Assert.ThrowsAsync(action); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Does.Contain(CustomErrorMessage)); + } + + [Test] + public async Task Should_Return_Custom_ErrorMessage_When_ThrowErrorOnFailure_Is_False() + { + var input = new Input { TableName = "TestTable" }; + var connection = new Connection + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = "Invalid", + }; + var options = DefaultOptions(); + options.ErrorMessageOnFailure = CustomErrorMessage; + options.ThrowErrorOnFailure = false; + + var result = await AzureTableStorage.DeleteTable(input, connection, options, CancellationToken.None); + + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Is.Not.Null); + Assert.That(result.Error.Message, Does.Contain(CustomErrorMessage)); + } + + [Test] + public void Should_Always_Throw_OperationCanceledException() + { + var input = new Input { TableName = "TestTable" }; + var options = DefaultOptions(); + options.ThrowErrorOnFailure = false; // Even with this set to false + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func action = async () => { await AzureTableStorage.DeleteTable(input, DefaultConnectionStringConnection(), options, cts.Token); }; + var ex = Assert.ThrowsAsync(action); + } + + [Test] + public void Should_Throw_ValidationException_When_TableName_Is_Empty() + { + var input = new Input { TableName = string.Empty }; + var connection = new Connection + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = "valid-connection-string", + }; + var options = DefaultOptions(); + + Func action = async () => { await AzureTableStorage.DeleteTable(input, connection, options, CancellationToken.None); }; + var ex = Assert.ThrowsAsync(action); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message, Does.Contain("TableName")); + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/Frends.AzureTableStorage.DeleteTable.Tests.csproj b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/Frends.AzureTableStorage.DeleteTable.Tests.csproj new file mode 100644 index 0000000..dd243d3 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/Frends.AzureTableStorage.DeleteTable.Tests.csproj @@ -0,0 +1,24 @@ + + + net8.0 + false + disable + + + + + + + + + + + + + + + + + + + diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs new file mode 100644 index 0000000..88e1dfa --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; +using Frends.AzureTableStorage.DeleteTable.Definitions; +using NUnit.Framework; + +namespace Frends.AzureTableStorage.DeleteTable.Tests; + +[TestFixture] +internal class FunctionalTests : TestBase +{ + private string tableName; + private Input input; + + [SetUp] + public async Task SetUp() + { + tableName = $"TestTable{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + input = new Input { TableName = tableName }; + + if (!string.IsNullOrEmpty(ConnectionString)) + { + var serviceClient = new TableServiceClient(ConnectionString); + await serviceClient.CreateTableIfNotExistsAsync(tableName); + } + } + + [TearDown] + public async Task TearDown() + { + if (!string.IsNullOrEmpty(ConnectionString)) + { + try + { + var serviceClient = new TableServiceClient(ConnectionString); + await serviceClient.DeleteTableAsync(tableName); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + public async Task DeleteTable_WithConnectionString_ShouldDeleteTable() + { + var result = await AzureTableStorage.DeleteTable(input, DefaultConnectionStringConnection(), DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(result.TableName, Is.EqualTo(input.TableName)); + Assert.That(result.Deleted, Is.True); + Assert.That(result.TableUri, Is.Not.Empty); + Assert.That(result.Error, Is.Null); + } + + [Test] + public async Task DeleteTable_WhenTableDoesNotExist_ShouldReturnDeletedFalse() + { + var nonExistentTable = $"NonExistent{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + var testInput = new Input { TableName = nonExistentTable }; + + var result = await AzureTableStorage.DeleteTable(testInput, DefaultConnectionStringConnection(), DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(result.TableName, Is.EqualTo(nonExistentTable)); + Assert.That(result.Deleted, Is.False); + Assert.That(result.Error, Is.Null); + } + + [Test] + public async Task DeleteTable_WithFailIfTableNotExists_ShouldReturnError() + { + var nonExistentTable = $"NonExistent{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + var testInput = new Input { TableName = nonExistentTable }; + + var options = DefaultOptions(); + options.FailIfTableNotExists = true; + options.ThrowErrorOnFailure = false; + var result = await AzureTableStorage.DeleteTable(testInput, DefaultConnectionStringConnection(), options, CancellationToken.None); + + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Is.Not.Null); + Assert.That(result.Error.Message, Does.Contain("does not exist")); + } + + [Test] + public async Task DeleteTable_WithOAuth2_ShouldDeleteTable() + { + var result = await AzureTableStorage.DeleteTable(input, DefaultOAuth2Connection(), DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(result.TableName, Is.EqualTo(input.TableName)); + Assert.That(result.Deleted, Is.True); + Assert.That(result.TableUri, Is.Not.Empty); + Assert.That(result.Error, Is.Null); + } + + [Test] + public async Task DeleteTable_WithSasToken_ShouldDeleteTable() + { + var result = await AzureTableStorage.DeleteTable(input, DefaultSasTokenConnection(), DefaultOptions(), CancellationToken.None); + + Assert.That(result.Success, Is.True); + Assert.That(result.TableName, Is.EqualTo(input.TableName)); + Assert.That(result.Deleted, Is.True); + Assert.That(result.TableUri, Is.Not.Empty); + Assert.That(result.Error, Is.Null); + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/GlobalSuppressions.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/GlobalSuppressions.cs new file mode 100644 index 0000000..7839b4e --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.SpecialRules", "SA0001::XmlCommentAnalysisDisabled", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:BracesMustNotBeOmitted", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Documentation checked by custom analyzers")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:FileMustHaveHeader", Justification = "Following Frends documentation guidelines")] diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/TestBase.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/TestBase.cs new file mode 100644 index 0000000..85d620d --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/TestBase.cs @@ -0,0 +1,69 @@ +using System; +using dotenv.net; +using Frends.AzureTableStorage.DeleteTable.Definitions; + +namespace Frends.AzureTableStorage.DeleteTable.Tests; + +internal abstract class TestBase +{ + internal TestBase() + { + DotEnv.Load(); + ConnectionString = GetEnvVarOrDefault("Frends_AzureTableStorage_ConnString", string.Empty); + AccountName = GetEnvVarOrDefault("Frends_AzureTableStorage_AccountName", string.Empty); + TenantId = GetEnvVarOrDefault("Frends_AzureTableStorage_TenantID", string.Empty); + ClientId = GetEnvVarOrDefault("Frends_AzureTableStorage_ClientID", string.Empty); + ClientSecret = GetEnvVarOrDefault("Frends_AzureTableStorage_ClientSecret", string.Empty); + SasToken = GetEnvVarOrDefault("Frends_AzureTableStorage_SasToken", string.Empty); + } + + protected string ConnectionString { get; set; } + + protected string AccountName { get; set; } + + protected string TenantId { get; set; } + + protected string ClientId { get; set; } + + protected string ClientSecret { get; set; } + + protected string SasToken { get; set; } + + protected static Options DefaultOptions() => new() + { + ThrowErrorOnFailure = true, + ErrorMessageOnFailure = string.Empty, + FailIfTableNotExists = false, + }; + + protected Connection DefaultConnectionStringConnection() => new() + { + ConnectionMethod = ConnectionMethod.ConnectionString, + ConnectionString = ConnectionString, + }; + + protected Connection DefaultOAuth2Connection() => new() + { + ConnectionMethod = ConnectionMethod.OAuth2, + StorageAccountName = AccountName, + TenantId = TenantId, + ApplicationId = ClientId, + ClientSecret = ClientSecret, + }; + + protected Connection DefaultSasTokenConnection() => new() + { + ConnectionMethod = ConnectionMethod.SasToken, + StorageAccountName = AccountName, + SasToken = SasToken, + }; + + protected Connection DefaultArcManagedIdentityConnection() => new() + { + ConnectionMethod = ConnectionMethod.ArcManagedIdentity, + StorageAccountName = AccountName, + }; + + private static string GetEnvVarOrDefault(string name, string defaultValue) => + Environment.GetEnvironmentVariable(name) ?? defaultValue; +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.sln b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.sln new file mode 100644 index 0000000..67e8266 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32112.339 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.AzureTableStorage.DeleteTable", "Frends.AzureTableStorage.DeleteTable\Frends.AzureTableStorage.DeleteTable.csproj", "{35C305C0-8108-4A98-BB1D-AFE5C926239E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.AzureTableStorage.DeleteTable.Tests", "Frends.AzureTableStorage.DeleteTable.Tests\Frends.AzureTableStorage.DeleteTable.Tests.csproj", "{8CA92187-8E4F-4414-803B-EC899479022E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{78F7F22E-6E20-4BCE-8362-0C558568B729}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + ..\.github\workflows\DeleteTable_test_on_main.yml = ..\.github\workflows\DeleteTable_test_on_main.yml + ..\.github\workflows\DeleteTable_test_on_push.yml = ..\.github\workflows\DeleteTable_test_on_push.yml + ..\.github\workflows\DeleteTable_release.yml = ..\.github\workflows\DeleteTable_release.yml + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.Build.0 = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {55BC6629-85C9-48D8-8CA2-B0046AF1AF4B} + EndGlobalSection +EndGlobal diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Attributes/RequiredIfAttribute.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Attributes/RequiredIfAttribute.cs new file mode 100644 index 0000000..3a14c01 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Attributes/RequiredIfAttribute.cs @@ -0,0 +1,34 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Frends.AzureTableStorage.DeleteTable.Attributes; + +/// +/// Validates that a property is required if another property has a specific value. +/// If a property is null, empty, or white space only, validation fails. +/// +[AttributeUsage(AttributeTargets.Property)] +internal class RequiredIfAttribute(string dependentProperty, params object[] targetValues) : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + var field = validationContext.ObjectType.GetProperty(dependentProperty); + + if (field == null) + return new ValidationResult($"Unknown property: {dependentProperty}"); + + var dependentValue = field.GetValue(validationContext.ObjectInstance); + + if (!targetValues.Contains(dependentValue)) return ValidationResult.Success; + + if (value == null + || (value is string s && string.IsNullOrWhiteSpace(s)) + || (value is Array { Length: 0 })) + { + return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} is required."); + } + + return ValidationResult.Success; + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Connection.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Connection.cs new file mode 100644 index 0000000..bb9dde4 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Connection.cs @@ -0,0 +1,130 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Frends.AzureTableStorage.DeleteTable.Attributes; + +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Connection parameters for Azure Table Storage. +/// +public class Connection +{ + /// + /// Connection method to use for connecting to Azure Table Storage. + /// + /// ConnectionMethod.ConnectionString + [DefaultValue(ConnectionMethod.ConnectionString)] + public ConnectionMethod ConnectionMethod { get; set; } = ConnectionMethod.ConnectionString; + + /// + /// Connection string for the Azure Storage Account. + /// Required when ConnectionMethod is ConnectionString. + /// + /// DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey;EndpointSuffix=core.windows.net + [DisplayFormat(DataFormatString = "Text")] + [PasswordPropertyText] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.ConnectionString)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.ConnectionString)] + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Application (Client) ID of Azure AD Application. + /// Required when ConnectionMethod is OAuth2. + /// + /// 12345678-1234-1234-1234-123456789012 + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.OAuth2)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.OAuth2)] + public string ApplicationId { get; set; } = string.Empty; + + /// + /// Azure AD Tenant ID. + /// Required when ConnectionMethod is OAuth2. + /// + /// 12345678-1234-1234-1234-123456789012 + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.OAuth2)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.OAuth2)] + public string TenantId { get; set; } = string.Empty; + + /// + /// Azure AD Application Client Secret. + /// Required when ConnectionMethod is OAuth2. + /// + /// your-client-secret + [DisplayFormat(DataFormatString = "Text")] + [PasswordPropertyText] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.OAuth2)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.OAuth2)] + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Shared Access Signature (SAS) token for authentication. + /// Required when ConnectionMethod is SasToken. + /// + /// ?sv=2021-06-08&ss=t&srt=sco&sp=rwdlacu&se=2024-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=... + [DisplayFormat(DataFormatString = "Text")] + [PasswordPropertyText] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.SasToken)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.SasToken)] + public string SasToken { get; set; } = string.Empty; + + /// + /// Name of the Azure Storage Account. + /// Required when ConnectionMethod is OAuth2, SasToken, ArcManagedIdentity, or ArcManagedIdentityCrossTenant. + /// + /// mystorageaccount + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [UIHint( + nameof(ConnectionMethod), + "", + ConnectionMethod.OAuth2, + ConnectionMethod.SasToken, + ConnectionMethod.ArcManagedIdentity, + ConnectionMethod.ArcManagedIdentityCrossTenant)] + [RequiredIf( + nameof(ConnectionMethod), + ConnectionMethod.OAuth2, + ConnectionMethod.SasToken, + ConnectionMethod.ArcManagedIdentity, + ConnectionMethod.ArcManagedIdentityCrossTenant)] + public string StorageAccountName { get; set; } = string.Empty; + + /// + /// Scopes used when authenticating with Arc Managed Identity Cross Tenant. + /// Required when ConnectionMethod is ArcManagedIdentityCrossTenant. + /// + /// ["api://AzureADTokenExchange/.default"] + [DefaultValue(null)] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.ArcManagedIdentityCrossTenant)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.ArcManagedIdentityCrossTenant)] + public string[] Scopes { get; set; } = []; + + /// + /// Target Tenant ID of Azure Tenant. + /// Required when ConnectionMethod is ArcManagedIdentityCrossTenant. + /// + /// 12345678-1234-1234-1234-123456789012 + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.ArcManagedIdentityCrossTenant)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.ArcManagedIdentityCrossTenant)] + public string TargetTenantId { get; set; } = string.Empty; + + /// + /// Target Client ID of Azure Tenant. + /// Required when ConnectionMethod is ArcManagedIdentityCrossTenant. + /// + /// 12345678-1234-1234-1234-123456789012 + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [UIHint(nameof(ConnectionMethod), "", ConnectionMethod.ArcManagedIdentityCrossTenant)] + [RequiredIf(nameof(ConnectionMethod), ConnectionMethod.ArcManagedIdentityCrossTenant)] + public string TargetClientId { get; set; } = string.Empty; +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Enums.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Enums.cs new file mode 100644 index 0000000..89c87dd --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Enums.cs @@ -0,0 +1,32 @@ +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Connection method for connecting to Azure Table Storage. +/// +public enum ConnectionMethod +{ + /// + /// Connection string. + /// + ConnectionString = 1, + + /// + /// OAuth2 authentication. + /// + OAuth2 = 2, + + /// + /// Shared Access Signature token. + /// + SasToken = 3, + + /// + /// Azure Arc Managed Identity. + /// + ArcManagedIdentity = 4, + + /// + /// Azure Arc Managed Identity Cross Tenant. + /// + ArcManagedIdentityCrossTenant = 5, +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Error.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Error.cs new file mode 100644 index 0000000..4204a4c --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Error.cs @@ -0,0 +1,21 @@ +using System; + +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Error information from a failed operation. +/// +public class Error +{ + /// + /// Error message describing what went wrong. + /// + /// Failed to delete table. + public string Message { get; set; } + + /// + /// Additional technical details about the error, such as HTTP status codes or Azure error codes. + /// + /// Status: 404 (Not Found), ErrorCode: RequestFailedException + public Exception AdditionalInfo { get; set; } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Input.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Input.cs new file mode 100644 index 0000000..401f7ca --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Input.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Essential parameters for deleting an Azure Table Storage table. +/// +public class Input +{ + /// + /// Name of the table to delete. Table names must be between 3 and 63 characters long, contain only alphanumeric characters, and cannot begin with a numeric character. + /// + /// Customers + [DisplayFormat(DataFormatString = "Text")] + [DefaultValue("")] + [Required] + public string TableName { get; set; } = string.Empty; +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Options.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Options.cs new file mode 100644 index 0000000..a48decd --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Options.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; + +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Optional parameters for controlling task behavior. +/// +public class Options +{ + /// + /// Whether to treat a non-existing table as a failure. + /// If false, the task succeeds with Deleted = false when the table doesn't exist. + /// If true, the non-existing table is treated as a failure. + /// + /// false + [DefaultValue(false)] + public bool FailIfTableNotExists { get; set; } = false; + + /// + /// Whether to throw an exception if the operation fails. + /// If set to false, the task returns a result with Success = false instead of throwing. + /// + /// true + [DefaultValue(true)] + public bool ThrowErrorOnFailure { get; set; } = true; + + /// + /// Custom error message to use when the operation fails. + /// If empty, the original error message is used. + /// + /// Failed to delete Azure table + [DefaultValue("")] + public string ErrorMessageOnFailure { get; set; } = string.Empty; +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Result.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Result.cs new file mode 100644 index 0000000..6f34b06 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Definitions/Result.cs @@ -0,0 +1,37 @@ +namespace Frends.AzureTableStorage.DeleteTable.Definitions; + +/// +/// Result of the DeleteTable operation. +/// +public class Result +{ + /// + /// Indicates whether the table deletion operation completed successfully. + /// + /// true + public bool Success { get; set; } + + /// + /// Name of the table that was deleted or didn't exist. + /// + /// Customers + public string TableName { get; set; } + + /// + /// Indicates whether the table was deleted (true) or didn't exist (false). + /// + /// true + public bool Deleted { get; set; } + + /// + /// URI to the table in Azure Table Storage before deletion. + /// + /// https://mystorageaccount.table.core.windows.net/Customers + public string TableUri { get; set; } + + /// + /// Error details if the operation failed. Null when Success is true. + /// + /// null + public Error Error { get; set; } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs new file mode 100644 index 0000000..f0bd036 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs @@ -0,0 +1,105 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Frends.AzureTableStorage.DeleteTable.Definitions; +using Frends.AzureTableStorage.DeleteTable.Helpers; + +namespace Frends.AzureTableStorage.DeleteTable; + +/// +/// Task class for Azure Table Storage operations. +/// +public static class AzureTableStorage +{ + /// + /// Deletes a table from Azure Table Storage. + /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends-AzureTableStorage-DeleteTable) + /// + /// Table parameters including the table name. + /// Azure Storage authentication parameters. + /// Optional behavior controls including error handling. + /// A cancellation token provided by Frends Platform. + /// object { bool Success, string TableName, bool Deleted, string TableUri, object Error { string Message, string AdditionalInfo } } + public static async Task DeleteTable( + [PropertyTab] Input input, + [PropertyTab] Connection connection, + [PropertyTab] Options options, + CancellationToken cancellationToken) + { + try + { + ValidationHandler.Run(input, connection, options); + + var serviceClient = ConnectionHandler.GetTableServiceClient(connection, cancellationToken); + + var tableClient = serviceClient.GetTableClient(input.TableName); + var tableUri = tableClient?.Uri?.ToString() ?? string.Empty; + + var response = await serviceClient.DeleteTableAsync(input.TableName, cancellationToken); + var status = response?.Status ?? 0; + + bool tableDeleted; + if (status == 204) + { + tableDeleted = true; + } + else if (status == 404) + { + var errorCode = GetODataErrorCode(response); + + if (errorCode != "ResourceNotFound") + throw new Exception($"Failed to delete table '{input.TableName}'. Status: {status} ({response?.ReasonPhrase}). Error code: '{errorCode}'."); + + if (options.FailIfTableNotExists) + throw new Exception($"Table '{input.TableName}' does not exist."); + + tableDeleted = false; + } + else + { + throw new Exception($"Failed to delete table '{input.TableName}'. Status: {status} ({response?.ReasonPhrase})."); + } + + return new Result + { + Success = true, + TableName = input.TableName, + Deleted = tableDeleted, + TableUri = tableUri, + Error = null, + }; + } + catch (Exception ex) + { + return ex.Handle(options); + } + } + + /// + /// Extracts the "odata.error.code" value from a raw error response, if present. + /// + private static string GetODataErrorCode(Azure.Response rawResponse) + { + try + { + var content = rawResponse?.Content; + if (content == null) + return null; + + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("odata.error", out var odataError) && + odataError.TryGetProperty("code", out var code)) + { + return code.GetString(); + } + } + catch + { + // Ignore parsing failures - fall back to null (treated as unknown/unexpected error). + } + + return null; + } +} \ No newline at end of file diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.csproj b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.csproj new file mode 100644 index 0000000..58ab3b1 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.csproj @@ -0,0 +1,44 @@ + + + net8.0 + latest + 1.0.0 + Frends + Copyright (c) 2026 Frends EiPaaS + Frends + Frends + Frends + MIT + true + Deletes a table from Azure Table Storage. + https://frends.com/ + https://github.com/FrendsPlatform/Frends.AzureTableStorage/tree/main/Frends.AzureTableStorage.DeleteTable + disable + + CS1591, + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/FrendsTaskMetadata.json b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/FrendsTaskMetadata.json new file mode 100644 index 0000000..41b0589 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/FrendsTaskMetadata.json @@ -0,0 +1,7 @@ +{ + "Tasks": [ + { + "TaskMethod": "Frends.AzureTableStorage.DeleteTable.AzureTableStorage.DeleteTable" + } + ] +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/GlobalSuppressions.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/GlobalSuppressions.cs new file mode 100644 index 0000000..4b4cc78 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:BracesMustNotBeOmitted", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:ElementsMustBeDocumented", Justification = "Documentation checked by custom analyzers")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1623:PropertySummaryDocumentationMustMatchAccessors", Justification = "Following Frends Tasks guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1629:DocumentationTextMustEndWithAPeriod", Justification = "Following Frends Tasks guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:FileMustHaveHeader", Justification = "Following Frends documentation guidelines")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:FileNameMustMatchTypeName", Justification = "Following Frends Tasks guidelines")] diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ConnectionHandler.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ConnectionHandler.cs new file mode 100644 index 0000000..103c32e --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ConnectionHandler.cs @@ -0,0 +1,115 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using Azure.Core; +using Azure.Data.Tables; +using Azure.Identity; +using Frends.AzureTableStorage.DeleteTable.Definitions; + +namespace Frends.AzureTableStorage.DeleteTable.Helpers; + +/// +/// Connection handler to connect with Azure Table Storage. +/// +public static class ConnectionHandler +{ + /// + /// Get Table Service Client. + /// + /// Connection task parameters + /// cancellation token + /// TableServiceClient object + public static TableServiceClient GetTableServiceClient( + Connection connection, + CancellationToken cancellationToken) + { + try + { + return connection.ConnectionMethod switch + { + ConnectionMethod.ConnectionString => GetTableServiceClientWithConnectionString(connection), + ConnectionMethod.SasToken => GetTableServiceClientWithSasToken(connection), + ConnectionMethod.OAuth2 => GetTableServiceClientWithOAuth2(connection), + ConnectionMethod.ArcManagedIdentity => GetTableServiceClientWithArcManagedIdentity(connection), + ConnectionMethod.ArcManagedIdentityCrossTenant => GetTableServiceClientWithArcManagedIdentityCrossTenant( + connection, + cancellationToken), + _ => throw new NotSupportedException(), + }; + } + catch (Exception ex) + { + throw new ArgumentException($"GetTableServiceClient error: {ex.Message}", ex); + } + } + + private static TableServiceClient GetTableServiceClientWithConnectionString(Connection connection) + { + return new TableServiceClient(connection.ConnectionString); + } + + private static TableServiceClient GetTableServiceClientWithSasToken(Connection connection) + { + return new TableServiceClient(GetUri(connection.StorageAccountName, connection.SasToken)); + } + + private static TableServiceClient GetTableServiceClientWithOAuth2(Connection connection) + { + return new TableServiceClient( + GetUri(connection.StorageAccountName), + new ClientSecretCredential( + connection.TenantId, + connection.ApplicationId, + connection.ClientSecret, + new ClientSecretCredentialOptions())); + } + + [ExcludeFromCodeCoverage(Justification = "We do not have environment prepared to test this connection")] + private static TableServiceClient GetTableServiceClientWithArcManagedIdentity(Connection connection) + { + { + var credentials = new ManagedIdentityCredential(); + + return new TableServiceClient(GetUri(connection.StorageAccountName), credentials); + } + } + + [ExcludeFromCodeCoverage(Justification = "We do not have environment prepared to test this connection")] + private static TableServiceClient GetTableServiceClientWithArcManagedIdentityCrossTenant( + Connection connection, + CancellationToken cancellationToken) + { + { + var credentials = new ManagedIdentityCredential(); + ClientAssertionCredential assertion = new( + connection.TargetTenantId, + connection.TargetClientId, + async _ => + { + var tokenRequestContext = new TokenRequestContext(connection.Scopes); + var accessToken = await credentials + .GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + return accessToken.Token; + }); + + return new TableServiceClient(GetUri(connection.StorageAccountName), assertion); + } + } + + private static Uri GetUri(string storageAccountName, string sasToken = null) + { + if (storageAccountName is null + || storageAccountName.Length < 3 + || storageAccountName.Length > 24 + || !storageAccountName.All(c => char.IsLower(c) || char.IsDigit(c))) + throw new ValidationException("StorageAccountName must be 3–24 lowercase letters or digits."); + + var normalizedSasToken = sasToken?.TrimStart('?'); + return sasToken is null + ? new Uri($"https://{storageAccountName}.table.core.windows.net") + : new Uri($"https://{storageAccountName}.table.core.windows.net?{normalizedSasToken}"); + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ErrorHandler.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ErrorHandler.cs new file mode 100644 index 0000000..2ccd3f9 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ErrorHandler.cs @@ -0,0 +1,56 @@ +using System; +using System.Runtime.ExceptionServices; +using Frends.AzureTableStorage.DeleteTable.Definitions; + +namespace Frends.AzureTableStorage.DeleteTable.Helpers; + +internal static class ErrorHandler +{ + /// + /// Converts an exception into a failed Result object or rethrows based on task options. + /// + /// The exception to handle. + /// Task options that control whether failures are returned as a Result object or thrown. + /// + /// When true, an OperationCanceledException is rethrown immediately. + /// When false, cancellation is handled like any other failure. + /// + /// A failed Result object when the exception is handled instead of rethrown. + internal static Result Handle(this Exception exception, Options options, bool throwCanceled = true) + { + ThrowIfCanceled(exception, throwCanceled); + if (options.ThrowErrorOnFailure) ThrowBaseException(exception, options.ErrorMessageOnFailure); + + return ReturnResult(exception, options.ErrorMessageOnFailure); + } + + private static void ThrowIfCanceled(Exception exception, bool throwCanceled = true) + { + if (throwCanceled && exception is OperationCanceledException) throw exception; + } + + private static void ThrowBaseException(Exception exception, string customMessage = null) + { + if (string.IsNullOrEmpty(customMessage)) + ExceptionDispatchInfo.Capture(exception).Throw(); + + throw new Exception(customMessage, exception); + } + + private static Result ReturnResult(Exception exception, string customMessage = null) + { + var errorMessage = string.IsNullOrEmpty(customMessage) + ? exception.Message + : $"{customMessage}: {exception.Message}"; + + return new Result + { + Success = false, + Error = new Error + { + Message = errorMessage, + AdditionalInfo = exception, + }, + }; + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ValidationHandler.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ValidationHandler.cs new file mode 100644 index 0000000..ad63c38 --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Helpers/ValidationHandler.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Frends.AzureTableStorage.DeleteTable.Helpers; + +/// +/// Validates objects by their ValidationAttributes. +/// +internal static class ValidationHandler +{ + internal static void Run(params object[] objects) + { + if (objects == null || objects.Length == 0) + throw new ValidationException("Validation failed:\nYou must provide objects to validate"); + var validationMessage = objects.Select(obj => obj.Validate()) + .Aggregate(string.Empty, (current, message) => string.Join("\n", current, message)); + + if (validationMessage.Trim() != string.Empty) + throw new ValidationException($"Validation failed:\n{validationMessage}"); + } + + private static string Validate(this T objectToValidate) + { + if (objectToValidate == null) return "Validated object can't be null!\n"; + var ctx = new ValidationContext(objectToValidate); + List validateResults = []; + Validator.TryValidateObject(objectToValidate, ctx, validateResults, true); + + return validateResults.Aggregate( + string.Empty, + (current, error) => string.Join("\n", current, $"{error.ErrorMessage}")); + } +} diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/migration.json b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/migration.json new file mode 100644 index 0000000..b9064fb --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/migration.json @@ -0,0 +1,12 @@ +[ + { + "Task": "Frends.AzureTableStorage.DeleteTable", + "Migrations": [ + { + "Version": "1.0.0", + "Description": "", + "Migration": [] + } + ] + } +] diff --git a/Frends.AzureTableStorage.DeleteTable/README.md b/Frends.AzureTableStorage.DeleteTable/README.md new file mode 100644 index 0000000..b322a4b --- /dev/null +++ b/Frends.AzureTableStorage.DeleteTable/README.md @@ -0,0 +1,34 @@ +# Frends.AzureTableStorage.DeleteTable + +Deletes a table from Azure Table Storage. + +[![DeleteTable_build](https://github.com/FrendsPlatform/Frends.AzureTableStorage/actions/workflows/DeleteTable_test_on_main.yml/badge.svg)](https://github.com/FrendsPlatform/Frends.AzureTableStorage/actions/workflows/DeleteTable_test_on_main.yml) +![Coverage](https://app-github-custom-badges.azurewebsites.net/Badge?key=FrendsPlatform/Frends.AzureTableStorage/Frends.AzureTableStorage.DeleteTable|main) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +## Installing + +You can install the Task via Frends UI Task View or by installing the NuGet package `Frends.AzureTableStorage.DeleteTable` from the NuGet Gallery. + +## Building + +### Clone a copy of the repository + +`git clone https://github.com/FrendsPlatform/Frends.AzureTableStorage.git` + +### Build the project + +`dotnet build` + +### Run tests + +Create a `.env` file in the `Frends.AzureTableStorage.DeleteTable.Tests` directory based on `.env.example` and configure your Azure Storage credentials. + +`dotnet test` + +### Create a NuGet package + +`dotnet pack --configuration Release` + +### StyleCop.Analyzers Version +This project uses StyleCop.Analyzers 1.2.0-beta.556, as recommended by the author, to get the latest fixes and improvements not available in the last stable release. From 2bd73e8c712ba74e6e16b7db40c07d938b386fd5 Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Fri, 4 Sep 2026 08:20:21 +0200 Subject: [PATCH 2/3] improve tests --- .../FunctionalTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs index 88e1dfa..1caaae9 100644 --- a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.Tests/FunctionalTests.cs @@ -53,6 +53,9 @@ public async Task DeleteTable_WithConnectionString_ShouldDeleteTable() Assert.That(result.Deleted, Is.True); Assert.That(result.TableUri, Is.Not.Empty); Assert.That(result.Error, Is.Null); + + var serviceClient = new TableServiceClient(ConnectionString); + Assert.That(await TableExistsAsync(serviceClient, tableName), Is.False); } [Test] @@ -95,6 +98,9 @@ public async Task DeleteTable_WithOAuth2_ShouldDeleteTable() Assert.That(result.Deleted, Is.True); Assert.That(result.TableUri, Is.Not.Empty); Assert.That(result.Error, Is.Null); + + var serviceClient = new TableServiceClient(ConnectionString); + Assert.That(await TableExistsAsync(serviceClient, tableName), Is.False); } [Test] @@ -107,5 +113,18 @@ public async Task DeleteTable_WithSasToken_ShouldDeleteTable() Assert.That(result.Deleted, Is.True); Assert.That(result.TableUri, Is.Not.Empty); Assert.That(result.Error, Is.Null); + + var serviceClient = new TableServiceClient(ConnectionString); + Assert.That(await TableExistsAsync(serviceClient, tableName), Is.False); + } + + private static async Task TableExistsAsync(TableServiceClient serviceClient, string tableName) + { + await foreach (var table in serviceClient.QueryAsync(filter: $"TableName eq '{tableName}'")) + { + return true; + } + + return false; } } From 38fbbf9131108a4e1f94d381600e6c7c03efa1b5 Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Tue, 8 Sep 2026 17:14:51 +0200 Subject: [PATCH 3/3] cr fixes --- .../CHANGELOG.md | 3 -- .../Frends.AzureTableStorage.DeleteTable.cs | 31 ------------------- 2 files changed, 34 deletions(-) diff --git a/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md b/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md index d7fe46f..63180ce 100644 --- a/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md +++ b/Frends.AzureTableStorage.DeleteTable/CHANGELOG.md @@ -5,6 +5,3 @@ ### Added - Initial implementation of DeleteTable task for Azure Table Storage -- Support for ConnectionString, OAuth2, SasToken, ArcManagedIdentity, and ArcManagedIdentityCrossTenant authentication -- FailIfTableNotExists option to control behavior when table doesn't exist -- Comprehensive test coverage including functional and error handling tests diff --git a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs index f0bd036..5456230 100644 --- a/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs +++ b/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable/Frends.AzureTableStorage.DeleteTable.cs @@ -47,11 +47,6 @@ public static async Task DeleteTable( } else if (status == 404) { - var errorCode = GetODataErrorCode(response); - - if (errorCode != "ResourceNotFound") - throw new Exception($"Failed to delete table '{input.TableName}'. Status: {status} ({response?.ReasonPhrase}). Error code: '{errorCode}'."); - if (options.FailIfTableNotExists) throw new Exception($"Table '{input.TableName}' does not exist."); @@ -76,30 +71,4 @@ public static async Task DeleteTable( return ex.Handle(options); } } - - /// - /// Extracts the "odata.error.code" value from a raw error response, if present. - /// - private static string GetODataErrorCode(Azure.Response rawResponse) - { - try - { - var content = rawResponse?.Content; - if (content == null) - return null; - - using var doc = JsonDocument.Parse(content); - if (doc.RootElement.TryGetProperty("odata.error", out var odataError) && - odataError.TryGetProperty("code", out var code)) - { - return code.GetString(); - } - } - catch - { - // Ignore parsing failures - fall back to null (treated as unknown/unexpected error). - } - - return null; - } } \ No newline at end of file