diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2a3bfc68..884f131b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -26,7 +26,7 @@ jobs: # options: -m 4g --cpus=2.0 sql: - image: mcr.microsoft.com/mssql/server + image: mcr.microsoft.com/mssql/server:2019-latest ports: - 1433:1433 env: @@ -36,16 +36,13 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set EnvVar for Test - run: | - echo "ConnectionStrings__Database=Data Source=localhost,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true" >> $GITHUB_ENV - - name: Setup .NET uses: actions/setup-dotnet@v1 with: dotnet-version: | 3.1.x 6.0.x + 7.0.x - name: Restore dependencies run: dotnet restore @@ -56,6 +53,13 @@ jobs: - name: Test run: dotnet test --filter "(TestCategory!=WithDB)&(TestCategory!=WithCosmos)" --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov.info + - name: Set EnvVar for Test + run: | + echo "ConnectionStrings__Database=Data Source=localhost,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true" >> $GITHUB_ENV + + - name: Create/Migrate DB + run: dotnet run all --project ./samples/My.Hr/My.Hr.Database --connection-varname ConnectionStrings__Database + - name: Test With DB run: dotnet test --filter TestCategory=WithDB --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov2.info @@ -63,5 +67,5 @@ jobs: run: dotnet test --filter Category=WithCosmos --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov3.info if: ${{ false }} - - name: Test Docker Build - run: docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml build --build-arg LOCAL=true + #- name: Test Docker Build + # run: docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml build --build-arg LOCAL=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f730709..99df03ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Represents the **NuGet** versions. +## v2.8.0 +- *Enhancement:* Added `CoreEx.EntityFrameworkCore` support for framework `net7.0`. +- *Enhancement:* Updated `ServiceBusSubscriberInvoker` to improve logging, including opportunities to inherit and add further before and after processing logging and/or monitoring. +- *Enhancement:* Updated `ServiceBusOrchestratedSubscriber` to perform a `LogInformation` on success. +- *Enhancement:* The `TypedHttpClientBase` will probe settings by `GetType().Name` to enable settings per implementation type as an overridding configurable option. +- *Fixed:* `HttpResult.CreateExtendedException` passes inner `HttpRequestException` for context. +- *Fixed:* `EventSubscriberOrchestrator.AmbiquousSubscriberHandling` is correctly set to `ErrorHandling.CriticalFailFast` by default. + ## v2.7.0 - *Enhancement:* Simplified usage for `TypedHttpClientCore` and `TypedHttpClientBase` such that all parameters with the exception of `HttpClient` default where not specified. - *Enhancement:* `IServiceCollection` extension methods for `CoreEx.Validation` and `CoreEx.FluentValidation` support option to include/exclude underlying interfaces where performing register using `AddValidator` and `AddValidators`. diff --git a/Common.targets b/Common.targets index e4b71235..270264be 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 2.7.0 + 2.8.0 preview Avanade Avanade diff --git a/CoreEx.sln b/CoreEx.sln index 1b5cb033..1a20a1c4 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md Common.targets = Common.targets CONTRIBUTING.md = CONTRIBUTING.md + docker-compose.myHr.override.yml = docker-compose.myHr.override.yml + docker-compose.myHr.yml = docker-compose.myHr.yml LICENCE = LICENCE nuget-publish.ps1 = nuget-publish.ps1 README.md = README.md diff --git a/samples/My.Hr/My.Hr.Database/Program.cs b/samples/My.Hr/My.Hr.Database/Program.cs index b272bcca..052137b3 100644 --- a/samples/My.Hr/My.Hr.Database/Program.cs +++ b/samples/My.Hr/My.Hr.Database/Program.cs @@ -1,5 +1,5 @@ -using DbEx.SqlServer.Console; -using System.Reflection; +using DbEx.Migration; +using DbEx.SqlServer.Console; namespace My.Hr.Database { @@ -13,20 +13,22 @@ public class Program /// /// The startup arguments. /// The status code whereby zero indicates success. - internal static Task Main(string[] args) => RunMigrator("Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true", null, args); + internal static Task Main(string[] args) => new SqlServerMigrationConsole("Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true") + .Configure(c => ConfigureMigrationArgs(c.Args)) + .RunAsync(args); - public static Task RunMigrator(string connectionString, Assembly? assembly = null, params string[] args) - => SqlServerMigrationConsole - .Create(connectionString) - .Configure(c => - { - c.Args.AcceptPrompts = true; - c.Args.ConnectionStringEnvironmentVariableName = "My_HrDb"; - c.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); - c.Args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); - if (assembly != null) - c.Args.AddAssembly(assembly); - }) - .RunAsync(args); + /// + /// Configure the . + /// + /// The . + /// The . + public static MigrationArgs ConfigureMigrationArgs(MigrationArgs args) + { + args.ConnectionStringEnvironmentVariableName = "My_HrDb"; + args.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true); + args.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i); + args.AddAssembly(); + return args; + } } } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj b/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj index ecf03cd9..de9664fb 100644 --- a/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj +++ b/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj @@ -22,7 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs b/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs index 13f8b08a..d2c23fe4 100644 --- a/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs +++ b/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs @@ -1,5 +1,8 @@ using System.Threading.Tasks; using Dapper; +using DbEx; +using DbEx.Migration; +using DbEx.SqlServer.Migration; using Microsoft.Data.SqlClient; using Pulumi; @@ -36,13 +39,14 @@ FROM [sys].[database_principals] }); } - public Task DeployDbSchemaAsync(string connectionString) + public Task DeployDbSchemaAsync(string connectionString) { if (Deployment.Instance.IsDryRun) // skip in dry run - return Task.FromResult(0); + return Task.FromResult(true); Log.Info($"Deploying DB schema using {connectionString}"); - return Database.Program.RunMigrator(connectionString, assembly: typeof(My.Hr.Database.Program).Assembly, "DeployWithData"); + var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.DeployWithData, connectionString)); + return new SqlServerMigration(args).MigrateAsync(); } } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs b/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs index f681adfc..0451dbeb 100644 --- a/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs +++ b/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs @@ -5,6 +5,6 @@ namespace My.Hr.Infra.Services; public interface IDbOperations { - Task DeployDbSchemaAsync(string connectionString); + Task DeployDbSchemaAsync(string connectionString); void ProvisionUsers(Input connectionString, string groupName); } \ No newline at end of file diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs index b48f0bb7..42bd62c8 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeControllerTest.cs @@ -14,6 +14,10 @@ using UnitTestEx.Expectations; using UnitTestEx.NUnit; using DbEx; +using Microsoft.Extensions.DependencyInjection; +using My.Hr.Business; +using DbEx.Migration; +using DbEx.SqlServer.Migration; namespace My.Hr.UnitTest { @@ -28,8 +32,10 @@ public static async Task Init() using var test = ApiTester.Create(); var cs = test.Configuration.GetConnectionString("Database"); - if (await Database.Program.RunMigrator(cs, typeof(EmployeeControllerTest).Assembly, MigrationCommand.ResetAndAll.ToString()).ConfigureAwait(false) != 0) - Assert.Fail("Database migration failed."); + var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.ResetAndDatabase, cs)).AddAssembly(); + var (Success, Output) = await new SqlServerMigration(args).MigrateAndLogAsync().ConfigureAwait(false); + if (!Success) + Assert.Fail(Output); } [Test] diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs index 97aa6b27..b92fcb69 100644 --- a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs +++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs @@ -1,7 +1,10 @@ using CoreEx.Entities; using CoreEx.Http; using DbEx; -using Microsoft.Extensions.Configuration; +using DbEx.Migration; +using DbEx.SqlServer.Migration; +using Microsoft.Extensions.DependencyInjection; +using My.Hr.Business; using My.Hr.Business.Models; using My.Hr.Functions; using My.Hr.Functions.Functions; @@ -25,9 +28,11 @@ public async Task Init() HttpConsts.IncludeFieldsQueryStringName = "include-fields"; using var test = FunctionTester.Create(); - var cs = test.Configuration.GetConnectionString("Database"); - if (await Database.Program.RunMigrator(cs, typeof(EmployeeControllerTest).Assembly, MigrationCommand.ResetAndAll.ToString()).ConfigureAwait(false) != 0) - Assert.Fail("Database migration failed."); + var settings = test.Services.GetRequiredService(); + var args = Database.Program.ConfigureMigrationArgs(new MigrationArgs(MigrationCommand.ResetAndDatabase, settings.ConnectionStrings__Database)).AddAssembly(); + var (Success, Output) = await new SqlServerMigration(args).MigrateAndLogAsync().ConfigureAwait(false); + if (!Success) + Assert.Fail(Output); } [Test] diff --git a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj index 3f0c6114..f770fa5b 100644 --- a/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj +++ b/samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj @@ -27,8 +27,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs index 173f8f76..ef65df83 100644 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs +++ b/src/CoreEx.Azure/ServiceBus/ServiceBusOrchestratedSubscriber.cs @@ -32,7 +32,7 @@ public class ServiceBusOrchestratedSubscriber : EventSubscriberBase /// The . /// The optional . /// The optional . - /// The optional . + /// The optional . /// The optional . public ServiceBusOrchestratedSubscriber(EventSubscriberOrchestrator orchestrator, ExecutionContext executionContext, SettingsBase settings, ILogger logger, EventSubscriberInvoker? eventSubscriberInvoker = null, ServiceBusSubscriberInvoker? serviceBusSubscriberInvoker = null, IEventDataConverter? eventDataConverter = null, IEventSerializer? eventSerializer = null) : base(eventDataConverter ?? new ServiceBusReceivedMessageEventDataConverter(eventSerializer ?? new CoreEx.Text.Json.EventDataSerializer()), executionContext, settings, logger, eventSubscriberInvoker) @@ -94,7 +94,9 @@ public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageAct } // Execute subscriber receive with the event. - await Orchestrator.ReceiveAsync(this, subscriber!, @event, cancellationToken).ConfigureAwait(false); + var success = await Orchestrator.ReceiveAsync(this, subscriber!, @event, cancellationToken).ConfigureAwait(false); + if (success) + Logger.LogInformation("{Type} executed {Subscriber} successfully - Service Bus message '{Message}'.", GetType().Name, subscriber!.GetType().Name, message.MessageId); }, (message, messageActions), cancellationToken); } } diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs index fee0c690..a6faf00f 100644 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs +++ b/src/CoreEx.Azure/ServiceBus/ServiceBusReceivedMessageEventDataConverter.cs @@ -31,6 +31,14 @@ public class ServiceBusReceivedMessageEventDataConverter : IEventDataConverterThis method is not supported; throws a . public Task ConvertToAsync(EventData @event, CancellationToken cancellationToken) => throw new NotSupportedException($"The {nameof(ServiceBusReceivedMessage)} constructor is internal; therefore, can not be instantiated."); + /// + public Task ConvertFromMetadataOnlyAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken) + { + var @event = new EventData(); + UpdateMetaDataWhereApplicable(message, @event); + return Task.FromResult(@event); + } + /// public async Task ConvertFromAsync(ServiceBusReceivedMessage message, Type? valueType, CancellationToken cancellationToken) { @@ -65,8 +73,10 @@ public async Task> ConvertFromAsync(ServiceBusReceivedMessage me } /// - /// Update the metadata from the message where the Id is not null; otherwise, assume deserialized from the body and carry on. + /// Updates the metadata from the where the is not null; otherwise, assume already updated. /// + /// The + /// The . private static void UpdateMetaDataWhereApplicable(ServiceBusReceivedMessage message, EventData @event) { if (@event.Id is not null) diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs index bf459022..3733aa91 100644 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs +++ b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriber.cs @@ -22,7 +22,7 @@ namespace CoreEx.Azure.ServiceBus /// message identifiers. Where the unhandled is this will bubble out for the Azure Function runtime/fabric to retry and automatically deadletter; otherwise, it will be /// immediately deadletted with a reason of or depending on the exception . /// The is invoked after each deserialization. - public class ServiceBusSubscriber : Events.EventSubscriberBase + public class ServiceBusSubscriber : EventSubscriberBase { /// /// Gets the name to access the . @@ -93,6 +93,7 @@ public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMessageAct // Invoke the actual function logic. await function(@event!).ConfigureAwait(false); + Logger.LogInformation("{Type} executed successfully - Service Bus message '{Message}'.", GetType().Name, message.MessageId); }, (message, messageActions), cancellationToken); } @@ -133,6 +134,7 @@ public Task ReceiveAsync(ServiceBusReceivedMessage message, ServiceBusMe // Invoke the actual function logic. await function(@event!).ConfigureAwait(false); + Logger.LogInformation("{Type} executed successfully - Service Bus message '{Message}'.", GetType().Name, message.MessageId); }, (message, messageActions), cancellationToken); } diff --git a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs index 3683acf7..27167864 100644 --- a/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs +++ b/src/CoreEx.Azure/ServiceBus/ServiceBusSubscriberInvoker.cs @@ -3,7 +3,6 @@ using Azure.Messaging.ServiceBus; using CoreEx.Abstractions; using CoreEx.Events; -using CoreEx.Http; using CoreEx.Invokers; using Microsoft.Azure.WebJobs.ServiceBus; using Microsoft.Extensions.Logging; @@ -31,15 +30,22 @@ protected async override Task OnInvokeAsync(EventSubscriberBas if (!string.IsNullOrEmpty(args.Message.CorrelationId)) invoker.ExecutionContext.CorrelationId = args.Message.CorrelationId; - var scope = invoker.Logger.BeginScope(new Dictionary() + var state = new Dictionary { - { HttpConsts.CorrelationIdHeaderName, invoker.ExecutionContext.CorrelationId }, - { "MessageId", args.Message.MessageId } - }); + { nameof(ServiceBusReceivedMessage.MessageId), args.Message.MessageId }, + { nameof(EventData.CorrelationId), invoker.ExecutionContext.CorrelationId } + }; + + // Convert to metadata only to enable logging of standard metadata. + var @event = await invoker.EventDataConverter.ConvertFromMetadataOnlyAsync(args.Message, cancellationToken).ConfigureAwait(false); + UpdateLoggerState(args.Message, @event, state); + var scope = invoker.Logger.BeginScope(state); + + OnBeforeMessageProcessing(invoker, args.Message); try { - invoker.Logger.LogDebug("Received Service Bus message '{Message}'.", args.Message.MessageId); + invoker.Logger.LogDebug("Received - Service Bus message '{Message}'.", args.Message.MessageId); // Leverage the EventSubscriberInvoker to manage execution and standardized exception handling. var result = await invoker.EventSubscriberInvoker.InvokeAsync(invoker, async (ct) => @@ -49,8 +55,9 @@ protected async override Task OnInvokeAsync(EventSubscriberBas }, invoker.Logger, cancellationToken).ConfigureAwait(false); // Everything is good, so complete the message. + invoker.Logger.LogDebug("Completing - Service Bus message '{Message}'.", args.Message.MessageId); await args.MessageActions.CompleteMessageAsync(args.Message, cancellationToken).ConfigureAwait(false); - invoker.Logger.LogDebug("Completed Service Bus message '{Message}'.", args.Message.MessageId); + invoker.Logger.LogDebug("Completed - Service Bus message '{Message}'.", args.Message.MessageId); return result; } @@ -60,8 +67,9 @@ protected async override Task OnInvokeAsync(EventSubscriberBas { if (eex.IsTransient) { - // Do not abandon the message when transient, as there may be a Retry Policy configured; otherwise, it will eventaully be dead-lettered by the host/runtime/fabric. - invoker.Logger.LogWarning("{Reason} while processing message '{Message}'. Processing attempt {Count}", eex.ErrorType, args.Message.MessageId, args.Message.DeliveryCount); + // Do not abandon the message when transient, as there may be a Retry Policy configured; otherwise, it should eventaully be dead-lettered by the host/runtime/fabric. + invoker.Logger.LogWarning(ex, "Retry - Service Bus message '{Message}'. [{Reason}] Processing attempt {Count}. {Error}", args.Message.MessageId, eex.ErrorType, args.Message.DeliveryCount, ex.Message); + OnAfterMessageProcessing(invoker, args.Message, ex); throw; } @@ -70,6 +78,8 @@ protected async override Task OnInvokeAsync(EventSubscriberBas else await DeadLetterExceptionAsync(invoker, args.Message, args.MessageActions, ErrorType.UnhandledError.ToString(), ex, cancellationToken).ConfigureAwait(false); + // It's been handled, swallow the exception and carry on. + OnAfterMessageProcessing(invoker, args.Message, ex); return default!; } finally @@ -83,13 +93,54 @@ protected async override Task OnInvokeAsync(EventSubscriberBas /// public static async Task DeadLetterExceptionAsync(EventSubscriberBase invoker, ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions, string errorReason, Exception exception, CancellationToken cancellationToken) { - invoker.Logger.LogError(exception, "{Reason} for Service Bus message '{Message}': {Error}", errorReason, message.MessageId, exception.Message); + invoker.Logger.LogDebug("Dead Lettering - Service Bus message '{Message}'. [{Reason}] {Error}", message.MessageId, errorReason, exception.Message); await messageActions.DeadLetterMessageAsync(message, errorReason, ToDeadLetterReason(exception.ToString()), cancellationToken).ConfigureAwait(false); + invoker.Logger.LogError(exception, "Dead Lettered - Service Bus message '{Message}'. [{Reason}] {Error}", message.MessageId, errorReason, exception.Message); } /// /// Shortens the reason text to 4096 characters, which is the maximum allowed length for a dead letter reason. /// private static string? ToDeadLetterReason(string? reason) => reason?[..Math.Min(reason.Length, 4096)]; + + /// + /// Update the from the . + /// + /// The . + /// The metadata only representation of the . + /// The state . + /// The and are automatically added prior. + /// The , , and properties represent the default implementation. + protected virtual void UpdateLoggerState(ServiceBusReceivedMessage message, EventData @event, IDictionary state) + { + if (!string.IsNullOrEmpty(@event.Subject)) + state.Add(nameof(EventData.Subject), @event.Subject); + + if (!string.IsNullOrEmpty(@event.Action)) + state.Add(nameof(EventData.Action), @event.Action); + + if (@event.Source != null) + state.Add(nameof(EventData.Source), @event.Source.ToString()); + + if (!string.IsNullOrEmpty(@event.Type)) + state.Add(nameof(EventData.Type), @event.Type); + } + + /// + /// Provides an opportunity to perform additional logging/monitoring before the processing occurs. + /// + /// The invoking . + /// The . + /// An should not be thrown within as this may result in an unexpected error. + protected virtual void OnBeforeMessageProcessing(EventSubscriberBase subscriber, ServiceBusReceivedMessage message) { } + + /// + /// Provides an opportunity to perform additional logging/monitoring after the processing occurs (including any corresponding invocation). + /// + /// The invoking . + /// The . + /// The corresponding where an error occured. + /// An should not be thrown within as this may result in an unexpected error. + protected virtual void OnAfterMessageProcessing(EventSubscriberBase subscriber, ServiceBusReceivedMessage message, Exception? exception) { } } } \ No newline at end of file diff --git a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs b/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs index dbd1c42f..49d8d642 100644 --- a/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs +++ b/src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs @@ -21,10 +21,10 @@ public static class DatabaseServiceCollectionExtensions /// The optional partition key. /// The optional destination name (i.e. queue or topic). /// The . - /// To turn off the execution of the (s) at runtime set the 'EventOutboxHostedService' configuration setting to false. + /// To turn off the execution of the (s) at runtime set the 'EventOutboxHostedService:Enabled' configuration setting to false. public static IServiceCollection AddSqlServerEventOutboxHostedService(this IServiceCollection services, Func eventOutboxDequeueFactory, string? partitionKey = null, string? destination = null) { - var exe = services.BuildServiceProvider().GetRequiredService().GetValue("EventOutboxHostedService"); + var exe = services.BuildServiceProvider().GetRequiredService().GetValue("EventOutboxHostedService__Enabled"); if (!exe.HasValue || exe.Value) { services.AddHostedService(sp => new EventOutboxHostedService(sp, sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService(), partitionKey, destination) diff --git a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs index 02b345ca..b47ba748 100644 --- a/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs +++ b/src/CoreEx.Database.SqlServer/Outbox/EventOutboxDequeueBase.cs @@ -15,7 +15,7 @@ namespace CoreEx.Database.SqlServer.Outbox { /// - /// Provides the base database outbox dequeue and corresponding . + /// Provides the base database outbox dequeue. /// /// The (being the unique event identifier) can be leveraged by the underlying messaging platform to perform duplicate checking. There is no guarantee that a dequeued event is on published more /// than once, the guarantee is at best at-least once semantics based on the implementation of the final . diff --git a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj index 0b3459bd..f7e28c4c 100644 --- a/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj +++ b/src/CoreEx.EntityFrameworkCore/CoreEx.EntityFrameworkCore.csproj @@ -1,7 +1,7 @@  - net6.0 + net6.0;net7.0 CoreEx.EntityFrameworkCore CoreEx CoreEx .NET Entity Framework Core (EF) extras. @@ -12,12 +12,16 @@ - + + + + + - + \ No newline at end of file diff --git a/src/CoreEx/CoreEx.csproj b/src/CoreEx/CoreEx.csproj index 098dfc28..f01883a2 100644 --- a/src/CoreEx/CoreEx.csproj +++ b/src/CoreEx/CoreEx.csproj @@ -25,7 +25,7 @@ - + diff --git a/src/CoreEx/Events/EventSubscriberBase.cs b/src/CoreEx/Events/EventSubscriberBase.cs index 704fada9..ba97eef4 100644 --- a/src/CoreEx/Events/EventSubscriberBase.cs +++ b/src/CoreEx/Events/EventSubscriberBase.cs @@ -106,7 +106,15 @@ protected EventSubscriberBase(IEventDataConverter eventDataConverter, ExecutionC public ErrorHandling InvalidDataHandling { get; set; } = ErrorHandling.ThrowSubscriberException; /// - /// Deserializes () the into the specified . + /// Deserializes () the into the specified value containg metadata only. + /// + /// The originating message. + /// The . + protected Task DeserializeEventMetaDataOnlyAsync(object originatingMessage, CancellationToken cancellationToken = default) + => EventDataConverter.ConvertFromMetadataOnlyAsync(originatingMessage, cancellationToken); + + /// + /// Deserializes () the into the specified value. /// /// The originating message. /// The . @@ -115,7 +123,7 @@ protected EventSubscriberBase(IEventDataConverter eventDataConverter, ExecutionC { try { - var @event = await EventDataConverter.ConvertFromAsync(originatingMessage, cancellationToken).ConfigureAwait(false)!; + var @event = await EventDataConverter.ConvertFromAsync(originatingMessage, cancellationToken).ConfigureAwait(false); if (@event is not null) return @event; } diff --git a/src/CoreEx/Events/EventSubscriberException.cs b/src/CoreEx/Events/EventSubscriberException.cs index 500fb49c..7863659d 100644 --- a/src/CoreEx/Events/EventSubscriberException.cs +++ b/src/CoreEx/Events/EventSubscriberException.cs @@ -37,7 +37,10 @@ public EventSubscriberException(string message) : base(message) { } /// private IExtendedException? InnerExtendedException => InnerException is IExtendedException eex ? eex : null; - /// + /// + /// Gets the error type/reason. + /// + /// See either the or for standard values. public string ErrorType => InnerExtendedException?.ErrorType ?? (ExceptionSource == EventSubscriberExceptionSource.Subscriber ? Abstractions.ErrorType.UnhandledError.ToString() : ExceptionSource.ToString()); /// diff --git a/src/CoreEx/Events/IEventConverterT.cs b/src/CoreEx/Events/IEventConverterT.cs index 2ae01d83..e97ba48b 100644 --- a/src/CoreEx/Events/IEventConverterT.cs +++ b/src/CoreEx/Events/IEventConverterT.cs @@ -15,6 +15,9 @@ public interface IEventDataConverter : IEventDataConverter where TMess /// async Task IEventDataConverter.ConvertToAsync(EventData @event, CancellationToken cancellationToken) => await ConvertToAsync(@event, cancellationToken).ConfigureAwait(false); + /// + Task IEventDataConverter.ConvertFromMetadataOnlyAsync(object message, CancellationToken cancellationToken) => ConvertFromMetadataOnlyAsync((TMessage)message, cancellationToken); + /// Task IEventDataConverter.ConvertFromAsync(object message, CancellationToken cancellationToken) => ConvertFromAsync(message, null, cancellationToken); @@ -32,6 +35,14 @@ public interface IEventDataConverter : IEventDataConverter where TMess /// The value. new Task ConvertToAsync(EventData @event, CancellationToken cancellationToken); + /// + /// Converts from a messaging sub-system value to an value the metadata properties only (that underlying should be ignored). + /// + /// The messaging sub-system value. + /// The . + /// The . + Task ConvertFromMetadataOnlyAsync(TMessage message, CancellationToken cancellationToken); + /// /// Converts from a to an value. /// diff --git a/src/CoreEx/Events/IEventDataConverter.cs b/src/CoreEx/Events/IEventDataConverter.cs index 58a9a4a5..675b5cf4 100644 --- a/src/CoreEx/Events/IEventDataConverter.cs +++ b/src/CoreEx/Events/IEventDataConverter.cs @@ -20,7 +20,15 @@ public interface IEventDataConverter Task ConvertToAsync(EventData @event, CancellationToken cancellationToken); /// - /// Converts from a messaging sub-system value to an . + /// Converts from a messaging sub-system value to an value the metadata properties only (that underlying should be ignored). + /// + /// The messaging sub-system value. + /// The . + /// The . + Task ConvertFromMetadataOnlyAsync(object message, CancellationToken cancellationToken); + + /// + /// Converts from a messaging sub-system value to an value. /// /// The messaging sub-system value. /// The . @@ -28,7 +36,7 @@ public interface IEventDataConverter public Task ConvertFromAsync(object message, CancellationToken cancellationToken) => ConvertFromAsync(message, null, cancellationToken); /// - /// Converts from a messaging sub-system value to an or depending on . + /// Converts from a messaging sub-system value to an or depending on . /// /// The messaging sub-system value. /// The . @@ -37,7 +45,7 @@ public interface IEventDataConverter Task ConvertFromAsync(object message, Type? valueType, CancellationToken cancellationToken); /// - /// Converts from a messaging sub-system value to an . + /// Converts from a messaging sub-system value to an . /// /// The . /// The messaging sub-system value. diff --git a/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs b/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs index bd158e9e..1e4a8c66 100644 --- a/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs +++ b/src/CoreEx/Events/Subscribing/EventSubscriberOrchestrator.cs @@ -56,9 +56,9 @@ public static Type[] GetSubscribers(bool includeInternalTypes = false public ErrorHandling NotSubscribedHandling { get; set; } = ErrorHandling.ThrowSubscriberException; /// - /// Gets or sets the where an event is encountered that has more than one subscriber (is ambiguous). Defaults to . + /// Gets or sets the where an event is encountered that has more than one subscriber (is ambiguous). Defaults to . /// - public ErrorHandling AmbiquousSubscriberHandling { get; set; } = ErrorHandling.ThrowSubscriberException; + public ErrorHandling AmbiquousSubscriberHandling { get; set; } = ErrorHandling.CriticalFailFast; /// /// Use (set) the where an event is encountered that has not been subscribed to. @@ -76,7 +76,7 @@ public EventSubscriberOrchestrator UseNotSubscribedHandling(ErrorHandling notSub /// /// The . /// The to support fluent-style method-chaining. - public EventSubscriberOrchestrator UseNotsubscribedHandling(ErrorHandling ambiquousSubscriberHandling) + public EventSubscriberOrchestrator UseAmbiquousSubscriberHandling(ErrorHandling ambiquousSubscriberHandling) { AmbiquousSubscriberHandling = ambiquousSubscriberHandling; return this; @@ -215,15 +215,17 @@ private bool TryMatchSubscriberInternal(EventData @event, out IEventSubscriber? /// The that should receive the . /// The . /// The . - public async virtual Task ReceiveAsync(EventSubscriberBase parent, IEventSubscriber subscriber, EventData @event, CancellationToken cancellationToken = default) + /// true indicates that the subscriber executed successfully; otherwise, false. + public async virtual Task ReceiveAsync(EventSubscriberBase parent, IEventSubscriber subscriber, EventData @event, CancellationToken cancellationToken = default) { if (parent is null) throw new ArgumentNullException(nameof(parent)); if (subscriber is null) throw new ArgumentNullException(nameof(subscriber)); if (@event is null) throw new ArgumentNullException(nameof(@event)); - await parent.EventSubscriberInvoker.InvokeAsync(subscriber, async (ct) => + return await parent.EventSubscriberInvoker.InvokeAsync(subscriber, async (ct) => { await subscriber!.ReceiveAsync(@event, ct); + return true; }, parent.Logger, cancellationToken).ConfigureAwait(false); } } diff --git a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs b/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs index e6dd2889..1a2fffca 100644 --- a/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs +++ b/src/CoreEx/Http/Extended/TypedHttpClientOptions.cs @@ -170,12 +170,13 @@ public TypedHttpClientOptions ThrowKnownException(bool useContentAsErrorMessage /// /// The number of times to retry. Defaults to . /// The base number of seconds to delay between retries. Defaults to . Delay will be exponential with each retry. - /// This is after each invocation; see . + /// This is after each invocation; see . + /// The is the number of additional retries that should be performed in addition to the initial request. public TypedHttpClientOptions WithRetry(int? count = null, double? seconds = null) { CheckDefaultNotBeingUpdatedInSendMode(); - var retryCount = _settings.HttpRetryCount; - var retrySeconds = _settings.HttpRetrySeconds; + var retryCount = (_owner is null ? null : _settings.GetValue($"{_owner.GetType().Name}__{nameof(SettingsBase.HttpRetryCount)}")) ?? _settings.HttpRetryCount; + var retrySeconds = (_owner is null ? null : _settings.GetValue($"{_owner.GetType().Name}__{nameof(SettingsBase.HttpRetrySeconds)}")) ?? _settings.HttpRetrySeconds; RetryCount = count ?? retryCount; if (RetryCount < 0) diff --git a/src/CoreEx/Http/HttpExtensions.cs b/src/CoreEx/Http/HttpExtensions.cs index 9d8866c7..a7986ce9 100644 --- a/src/CoreEx/Http/HttpExtensions.cs +++ b/src/CoreEx/Http/HttpExtensions.cs @@ -276,6 +276,9 @@ public static void AddPagingResult(this IHeaderDictionary headers, PagingResult? return null; var content = response.Content == null ? null : await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(content)) + content = $"Response status code does not indicate success: {(int)response.StatusCode} ({(string.IsNullOrEmpty(response.ReasonPhrase) ? response.StatusCode : response.ReasonPhrase)})."; + return HttpResult.CreateExtendedException(response, content, useContentAsErrorMessage); } diff --git a/src/CoreEx/Http/HttpResult.cs b/src/CoreEx/Http/HttpResult.cs index f290a788..35e0fd0a 100644 --- a/src/CoreEx/Http/HttpResult.cs +++ b/src/CoreEx/Http/HttpResult.cs @@ -219,22 +219,22 @@ public HttpResult ThrowOnError(bool throwKnownException = true, bool useContentA { case HttpStatusCode.BadRequest: if (errorType == Abstractions.ErrorType.BusinessError) - return new BusinessException(message); + return new BusinessException(message, new HttpRequestException(content)); else { var mic = CreateMessageItems(content); if (mic == null) - return new ValidationException(message); + return new ValidationException(message, new HttpRequestException(content)); else return new ValidationException(mic); } - case HttpStatusCode.Forbidden: return new AuthenticationException(message); - case HttpStatusCode.Unauthorized: return new AuthorizationException(message); - case HttpStatusCode.PreconditionFailed: return new ConcurrencyException(message); - case HttpStatusCode.Conflict: return errorType == Abstractions.ErrorType.DuplicateError ? new DuplicateException(message) : new ConflictException(message); - case HttpStatusCode.NotFound: return new NotFoundException(message); - case HttpStatusCode.ServiceUnavailable: return new TransientException(message); + case HttpStatusCode.Forbidden: return new AuthenticationException(message, new HttpRequestException(content)); + case HttpStatusCode.Unauthorized: return new AuthorizationException(message, new HttpRequestException(content)); + case HttpStatusCode.PreconditionFailed: return new ConcurrencyException(message, new HttpRequestException(content)); + case HttpStatusCode.Conflict: return errorType == Abstractions.ErrorType.DuplicateError ? new DuplicateException(message, new HttpRequestException(content)) : new ConflictException(message, new HttpRequestException(content)); + case HttpStatusCode.NotFound: return new NotFoundException(message, new HttpRequestException(content)); + case HttpStatusCode.ServiceUnavailable: return new TransientException(message, new HttpRequestException(content)); default: return null; } } diff --git a/src/CoreEx/Http/TypedHttpClientBaseT.cs b/src/CoreEx/Http/TypedHttpClientBaseT.cs index d549ac37..fde6ca65 100644 --- a/src/CoreEx/Http/TypedHttpClientBaseT.cs +++ b/src/CoreEx/Http/TypedHttpClientBaseT.cs @@ -140,7 +140,8 @@ public TSelf ThrowKnownException(bool useContentAsErrorMessage = false) /// The number of times to retry. Defaults to . /// The base number of seconds to delay between retries. Defaults to . Delay will be exponential with each retry. /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . + /// The is the number of additional retries that should be performed in addition to the initial request. + /// This references the equivalent method within the . This is after each invocation; see . public TSelf WithRetry(int? count = null, double? seconds = null) { SendOptions.WithRetry(count, seconds); @@ -223,11 +224,11 @@ public TSelf WithTimeout(TimeSpan timeout) } /// - /// Sets max retry delay that polly retries will be capped with (this affects mostly 429 and 503 responses that can return Retry-After header). - /// Default is 30s but it can be overridden for async calls (e.g. when using service bus trigger). + /// Sets the maximum retry delay that polly retries will be capped with (this affects mostly 429 and 503 responses that can return Retry-After header). /// /// This instance to support fluent-style method-chaining. - /// This references the equivalent method within the . This is after each invocation; see . + /// Default is 30 seconds but it can be overridden for async calls (e.g. when using Azure Service Bus trigger). + /// This references the equivalent method within the . This is after each invocation; see . public TSelf WithMaxRetryDelay(TimeSpan maxRetryDelay) { SendOptions.WithMaxRetryDelay(maxRetryDelay); @@ -337,7 +338,7 @@ protected override async Task SendAsync(HttpRequestMessage } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - throw new TimeoutException(); + throw new TimeoutException("The configured timeout for the HTTP send has been exceeded and therefore terminated."); } }).ConfigureAwait(false); ; @@ -346,7 +347,7 @@ protected override async Task SendAsync(HttpRequestMessage catch (Exception ex) when (ex is TimeoutException || ex is SocketException) { // Both TimeoutException and SocketException are transient and indicate a connection was terminated. - throw new TransientException("Timeout when calling service", ex); + throw new TransientException("Timeout when calling service.", ex); } catch (HttpRequestException hrex) { @@ -392,7 +393,7 @@ protected override async Task SendAsync(HttpRequestMessage /// private CancellationToken SetCancellationBasedOnTimeout(CancellationToken cancellationToken, out CancellationTokenSource? cts) { - var timeout = SendOptions.Timeout ?? TimeSpan.FromSeconds(Settings.HttpTimeoutSeconds); + var timeout = SendOptions.Timeout ?? TimeSpan.FromSeconds(Settings.GetValue($"{GetType().Name}__{nameof(SettingsBase.HttpTimeoutSeconds)}") ?? Settings.HttpTimeoutSeconds); if (timeout == Timeout.InfiniteTimeSpan) { // No need to create a CTS if there's no timeout diff --git a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj index 533bbe15..e6082273 100644 --- a/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj +++ b/tests/CoreEx.Cosmos.Test/CoreEx.Cosmos.Test.csproj @@ -34,7 +34,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/CoreEx.Test.csproj b/tests/CoreEx.Test/CoreEx.Test.csproj index 1aba784d..f71cdb50 100644 --- a/tests/CoreEx.Test/CoreEx.Test.csproj +++ b/tests/CoreEx.Test/CoreEx.Test.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs b/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs index 19ab2cdc..1b692f52 100644 --- a/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs +++ b/tests/CoreEx.Test/Framework/Events/Subscribing/EventSubscriberOrchestratorTest.cs @@ -75,7 +75,7 @@ public void NoMatch_Ambiquous() var sb = SetUpServiceProvider(sc => sc.AddScoped()); var ees = sb.GetRequiredService(); - var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber(); + var eso = new EventSubscriberOrchestrator().AddSubscriber().AddSubscriber().UseAmbiquousSubscriberHandling(ErrorHandling.None); var esex = Assert.Throws(() => eso.TryMatchSubscriber(ees, new EventData { Subject = "my.hr.employee", Type = "blah.blah", Action = "deleted" }, out var subscriber, out var valueType)); Assert.That(esex!.ExceptionSource, Is.EqualTo(EventSubscriberExceptionSource.OrchestratorAmbiquousSubscriber)); @@ -100,9 +100,9 @@ public void Match_With_ExtendedMatchMethod() Assert.That(valueType2, Is.Null); } - [Test] public async Task Receive_Unhandled_None() => await ReceiveTest(null, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), false, message: "Unhandled exception."); - [Test] public async Task Receive_Unhandled_Exception() => await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.ThrowSubscriberException, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), true, message: "Unhandled exception."); - [Test] public async Task Receive_Unhandled_CompleteSilent() => await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.CompleteAsSilent, () => throw new System.NotImplementedException("Unhandled exception.")); + [Test] public async Task Receive_Unhandled_None() => Assert.IsFalse(await ReceiveTest(null, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), false, message: "Unhandled exception.")); + [Test] public async Task Receive_Unhandled_Exception() => Assert.IsFalse(await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.ThrowSubscriberException, () => throw new System.NotImplementedException("Unhandled exception."), typeof(System.NotImplementedException), true, message: "Unhandled exception.")); + [Test] public async Task Receive_Unhandled_CompleteSilent() => Assert.IsFalse(await ReceiveTest(ms => ms._UnhandledHandling = ErrorHandling.CompleteAsSilent, () => throw new System.NotImplementedException("Unhandled exception."))); [Test] public async Task Receive_Unhandled_FailFast() { @@ -135,6 +135,8 @@ [Test] public async Task Receive_Unhandled_FailFast() [Test] public async Task Receive_Transient_Exception() => await ReceiveTest(ms => ms._TransientHandling = ErrorHandling.ThrowSubscriberException, () => throw new TransientException(), typeof(TransientException), true, false); [Test] public async Task Receive_Transient_CompleteSilent() => await ReceiveTest(ms => ms._TransientHandling = ErrorHandling.CompleteAsSilent, () => throw new TransientException()); + [Test] public async Task Receive_Success() => Assert.IsTrue(await ReceiveTest(null, () => { }, null, false)); + [Test] public void GetSubscriber() { @@ -158,7 +160,7 @@ private ServiceProvider SetUpServiceProvider(Action? action return sc.BuildServiceProvider(); } - private async Task ReceiveTest(System.Action? setAction, System.Action receiveAction, System.Type? exceptionType = null, bool eventSubscriberException = true, bool isTransient = false, string? message = null, EventData? ed = null) + private async Task ReceiveTest(System.Action? setAction, System.Action receiveAction, System.Type? exceptionType = null, bool eventSubscriberException = true, bool isTransient = false, string? message = null, EventData? ed = null) { var ms = new ModifySubscriber(); var sb = SetUpServiceProvider(sc => sc.AddSingleton(ms)); @@ -173,8 +175,9 @@ private async Task ReceiveTest(System.Action? setAction, Syste try { - await eso.ReceiveAsync(ees, subscriber!, ed ?? new EventData { Value = new Employee { Id = 1, Name = "Bob" } }); + var success = await eso.ReceiveAsync(ees, subscriber!, ed ?? new EventData { Value = new Employee { Id = 1, Name = "Bob" } }); Assert.IsNull(exceptionType, "Expected an exception!"); + return success; } catch (EventSubscriberException esex) { @@ -201,6 +204,8 @@ private async Task ReceiveTest(System.Action? setAction, Syste ms._ConcurrencyHandling = ErrorHandling.None; ms._InvalidDataHandling = ErrorHandling.None; } + + return false; } [EventSubscriber("my.hr.employee", "created", "updated")]