diff --git a/CallbackHandler.BusinessLogic.Tests/Mediator/MediatorTests.cs b/CallbackHandler.BusinessLogic.Tests/Mediator/MediatorTests.cs index a6a79f2..8e276be 100644 --- a/CallbackHandler.BusinessLogic.Tests/Mediator/MediatorTests.cs +++ b/CallbackHandler.BusinessLogic.Tests/Mediator/MediatorTests.cs @@ -14,10 +14,12 @@ namespace CallbackHandler.BusinessLogic.Tests.Mediator; +using CallbackHandler.Bootstrapper; using BusinessLogic.Services; using Lamar; using Microsoft.Extensions.DependencyInjection; using Shared.General; +using Shared.Serialisation; public class MediatorTests { @@ -38,18 +40,17 @@ public async Task Mediator_Send_RequestHandled() hostingEnvironment.ApplicationName.Getter().Returns("Test Application"); ServiceRegistry services = new(); - Startup s = new(hostingEnvironment.Instance()); IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(TestData.DefaultAppSettings).Build(); //ConfigurationReader.Initialise(configurationRoot); - Startup.Configuration = configurationRoot; - - this.AddTestRegistrations(services, hostingEnvironment.Instance()); - s.ConfigureContainer(services); - Startup.Container.AssertConfigurationIsValid(AssertMode.Full); + this.AddTestRegistrations(services, hostingEnvironment.Instance(), configurationRoot); + services.AddCallbackHandlerRegistries(configurationRoot); + Container container = new(services); + container.AssertConfigurationIsValid(AssertMode.Full); + StringSerialiser.Initialise(container.GetRequiredService()); List errors = new(); - IMediator mediator = Startup.Container.GetService(); + IMediator mediator = container.GetService(); foreach (IBaseRequest baseRequest in this.Requests) { try @@ -92,7 +93,8 @@ private IConfigurationRoot SetupMemoryConfiguration() } private void AddTestRegistrations(ServiceRegistry services, - IWebHostEnvironment hostingEnvironment) + IWebHostEnvironment hostingEnvironment, + IConfiguration configuration) { services.AddLogging(); DiagnosticListener diagnosticSource = new(hostingEnvironment.ApplicationName); @@ -100,7 +102,7 @@ private void AddTestRegistrations(ServiceRegistry services, services.AddSingleton(diagnosticSource); services.AddSingleton(hostingEnvironment); services.AddSingleton(hostingEnvironment); - services.AddSingleton(Startup.Configuration); + services.AddSingleton(configuration); services.OverrideServices(s => { s.AddSingleton(); }); } diff --git a/CallbackHandler.DataTransferObjects/CallbackMessage.cs b/CallbackHandler.DataTransferObjects/CallbackMessage.cs index 467bbe2..0af4c97 100644 --- a/CallbackHandler.DataTransferObjects/CallbackMessage.cs +++ b/CallbackHandler.DataTransferObjects/CallbackMessage.cs @@ -1,26 +1,25 @@ -using System; -using System.Collections.Generic; +using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Text.Json.Serialization; -using System.Threading.Tasks; namespace CallbackHandler.DataTransferObjects { [ExcludeFromCodeCoverage] public class CallbackMessage { + [Description("Original callback payload serialized as a JSON string.")] public String Message { get; set; } + [Description(".NET type name recorded for the original callback payload.")] public String TypeString { get; set; } + [Description("Reference supplied with the original deposit callback.")] public String Reference { get; set; } } - public class CallbackResponse { + public class CallbackResponse + { + [Description("Identifier assigned to the recorded callback. Use it to retrieve the callback.")] public Guid CallbackId { get; set; } } - - } diff --git a/CallbackHandler.DataTransferObjects/Deposit.cs b/CallbackHandler.DataTransferObjects/Deposit.cs index b66196d..9b07ee0 100644 --- a/CallbackHandler.DataTransferObjects/Deposit.cs +++ b/CallbackHandler.DataTransferObjects/Deposit.cs @@ -1,23 +1,31 @@ namespace CallbackHandler.DataTransferObjects { using System; + using System.ComponentModel; using System.Diagnostics.CodeAnalysis; [ExcludeFromCodeCoverage] public class Deposit { + [Description("Account number associated with the deposit.")] public String AccountNumber { get; set; } + [Description("Amount deposited.")] public Decimal Amount { get; set; } + [Description("Date and time when the deposit occurred.")] public DateTime DateTime { get; set; } + [Description("Identifier of the deposit event.")] public Guid DepositId { get; set; } + [Description("Identifier of the host that originated the deposit.")] public Guid HostIdentifier { get; set; } + [Description("Reference used to identify or correlate the deposit.")] public String Reference { get; set; } + [Description("Sort code associated with the account.")] public String SortCode { get; set; } } -} \ No newline at end of file +} diff --git a/CallbackHandler.IntegrationTests/CallbackHandler.IntegrationTests.csproj b/CallbackHandler.IntegrationTests/CallbackHandler.IntegrationTests.csproj index 9245477..cd9ec57 100644 --- a/CallbackHandler.IntegrationTests/CallbackHandler.IntegrationTests.csproj +++ b/CallbackHandler.IntegrationTests/CallbackHandler.IntegrationTests.csproj @@ -11,6 +11,7 @@ + diff --git a/CallbackHandler.IntegrationTests/Common/DockerHelper.cs b/CallbackHandler.IntegrationTests/Common/DockerHelper.cs index f855766..bd8fa22 100644 --- a/CallbackHandler.IntegrationTests/Common/DockerHelper.cs +++ b/CallbackHandler.IntegrationTests/Common/DockerHelper.cs @@ -1,4 +1,5 @@ using EventStore.Client; +using KurrentDB.Client; using SecurityService.Client; using Shared.IntegrationTesting; using Shared.Serialisation; @@ -15,7 +16,10 @@ public DockerHelper() { public ISecurityServiceClient SecurityServiceClient; public ITransactionProcessorClient TransactionProcessorClient; - public EventStoreProjectionManagementClient ProjectionManagementClient; + + public EventStoreProjectionManagementClient LegacyProjectionManagementClient; + + public KurrentDBProjectionManagementClient ProjectionManagementClient; public String AccessToken; @@ -52,6 +56,18 @@ public Int32 GetCallbackHandlerPort() return this.CallbackHandlerPort; } + protected virtual EventStoreClientSettings ConfigureLegacyEventstoreDbSettings(String username = "admin", String password = "changeit") { + String connectionString = $"esdb://{username}:{password}@127.0.0.1:{this.KurrentDbHttpPort}"; + + connectionString = this.IsSecureKurrentDb switch { + true => $"{connectionString}?tls=true&tlsVerifyCert=false", + _ => $"{connectionString}?tls=false&tlsVerifyCert=false" + }; + + return EventStoreClientSettings.Create(connectionString); + } + + public override async Task StartContainersForScenarioRun(String scenarioName, DockerServices dockerServices, CancellationToken cancellationToken) { await base.StartContainersForScenarioRun(scenarioName, dockerServices, cancellationToken); @@ -65,15 +81,13 @@ public override async Task StartContainersForScenarioRun(String scenarioName, ServerCertificateCustomValidationCallback = (message, certificate2, arg3, - arg4) => - { - return true; - } + arg4) => true }; HttpClient httpClient = new HttpClient(clientHandler); this.SecurityServiceClient = new SecurityServiceClient(SecurityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); this.TransactionProcessorClient = new TransactionProcessorClient(TransactionProcessorBaseAddressResolver, httpClient, Serialise, Deserialise); - this.ProjectionManagementClient = new EventStoreProjectionManagementClient(ConfigureEventStoreSettings()); + this.ProjectionManagementClient = new KurrentDBProjectionManagementClient(this.ConfigureKurrentDbSettings()); + this.LegacyProjectionManagementClient = new EventStoreProjectionManagementClient(this.ConfigureLegacyEventstoreDbSettings()); this.TestHostHttpClient = new HttpClient(clientHandler); this.TestHostHttpClient.BaseAddress = new Uri($"http://127.0.0.1:{this.TestHostServicePort}"); this.AgencyBankingClient = new AgencyBankingClient(TestHostServiceBaseAddressResolver, httpClient, Serialise_CamelCase, this.Deserialise_CamelCase); diff --git a/CallbackHandler.IntegrationTests/Common/GenericSteps.cs b/CallbackHandler.IntegrationTests/Common/GenericSteps.cs index 571962b..d335804 100644 --- a/CallbackHandler.IntegrationTests/Common/GenericSteps.cs +++ b/CallbackHandler.IntegrationTests/Common/GenericSteps.cs @@ -30,7 +30,7 @@ public async Task StartSystem() LogManager.AddHiddenAssembly(typeof(NlogLogger).Assembly); DockerServices dockerServices = DockerServices.SecurityService | DockerServices.TransactionProcessor | - DockerServices.CallbackHandler | DockerServices.EventStore | + DockerServices.CallbackHandler | DockerServices.KurrentDb | DockerServices.SqlServer | DockerServices.TestHost; this.TestingContext.DockerHelper = new DockerHelper(); diff --git a/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature b/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature index d34b290..372fdd3 100644 --- a/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature +++ b/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature @@ -32,9 +32,13 @@ Given I create the following api scopes | MerchantName | AddressLine1 | Town | Region | PostalCode | Country | ContactName | EmailAddress | EstateName | | Test Merchant 1 | Address Line 1 | TestTown | Test Region | TE57 1NG | United Kingdom | Test Contact 1 | testcontact1@merchant1.co.uk | Test Estate 1 | -Scenario: Process a Bank Deposit Callback +Scenario Outline: Process a Bank Deposit Callback Given I have the following Bank Deposit Callbacks | Amount | DateTime | DepositId | HostIdentifier | SortCode | AccountNumber | | 100.00 | Today | 6AE04AFC-D7F8-4936-A3A2-DCA177CAA106 | DC4A7DDA-45A1-4D5B-8D46-21FA99A3868E | 11-22-33 | 12345678 | When I send the requests to the callback handler for deposits Then the deposit records are recorded + +Examples: +| APIVersion | +| 1 | \ No newline at end of file diff --git a/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature.cs b/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature.cs index 8cab40a..36a70f1 100644 --- a/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature.cs +++ b/CallbackHandler.IntegrationTests/Features/BankDepositCallback.feature.cs @@ -229,11 +229,13 @@ await testRunner.GivenAsync("I have a token to access the estate management and [global::NUnit.Framework.TestAttribute()] [global::NUnit.Framework.DescriptionAttribute("Process a Bank Deposit Callback")] - public async global::System.Threading.Tasks.Task ProcessABankDepositCallback() + [global::NUnit.Framework.TestCaseAttribute("1", "0", null)] + public async global::System.Threading.Tasks.Task ProcessABankDepositCallback(string aPIVersion, string @__pickleIndex, string[] exampleTags) { - string[] tagsOfScenario = ((string[])(null)); + string[] tagsOfScenario = exampleTags; global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); - string pickleIndex = "0"; + argumentsOfScenario.Add("APIVersion", aPIVersion); + string pickleIndex = @__pickleIndex; global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Process a Bank Deposit Callback", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; diff --git a/CallbackHandler.IntegrationTests/Shared/SharedSteps.cs b/CallbackHandler.IntegrationTests/Shared/SharedSteps.cs index 0835a07..87171b8 100644 --- a/CallbackHandler.IntegrationTests/Shared/SharedSteps.cs +++ b/CallbackHandler.IntegrationTests/Shared/SharedSteps.cs @@ -38,7 +38,7 @@ public SharedSteps(ScenarioContext scenarioContext, this.SecurityServiceSteps = new SecurityServiceSteps(testingContext.DockerHelper.SecurityServiceClient, testingContext.DockerHelper.AccessToken); this.TransactionProcessorSteps = new TransactionProcessorSteps(testingContext.DockerHelper.TransactionProcessorClient, testingContext.DockerHelper.TestHostHttpClient, - testingContext.DockerHelper.ProjectionManagementClient, testingContext.DockerHelper.AgencyBankingClient); + testingContext.DockerHelper.LegacyProjectionManagementClient, testingContext.DockerHelper.AgencyBankingClient); } @@ -60,13 +60,14 @@ public async Task GivenIHaveTheFollowingBankDepositCallbacks(DataTable table) } [When(@"I send the requests to the callback handler for deposits")] - public async Task WhenISendTheRequestsToTheCallbackHandlerForDeposits() - { + public async Task WhenISendTheRequestsToTheCallbackHandlerForDeposits() { + String apiVersion = ScenarioContext.ScenarioInfo.Arguments["APIVersion"].ToString(); + using HttpClient client = new(); foreach (var testingContextDeposit in this.TestingContext.Deposits) { String requestUri = - $"http://localhost:{this.TestingContext.DockerHelper.GetCallbackHandlerPort()}/api/callbacks"; + $"http://localhost:{this.TestingContext.DockerHelper.GetCallbackHandlerPort()}/api/v{apiVersion}/callbacks"; HttpRequestMessage msg = new(HttpMethod.Post, requestUri); var payload = StringSerialiser.Serialise(testingContextDeposit); @@ -87,11 +88,12 @@ public async Task WhenISendTheRequestsToTheCallbackHandlerForDeposits() [Then("the deposit records are recorded")] public async Task ThenTheDepositRecordsAreRecorded() { + String apiVersion = ScenarioContext.ScenarioInfo.Arguments["APIVersion"].ToString(); using HttpClient client = new(); foreach (var sentCallback in this.TestingContext.SentCallbacks) { String requestUri = - $"http://localhost:{this.TestingContext.DockerHelper.GetCallbackHandlerPort()}/api/callbacks/{sentCallback.Key}"; + $"http://localhost:{this.TestingContext.DockerHelper.GetCallbackHandlerPort()}/api/v{apiVersion}/callbacks/{sentCallback.Key}"; HttpRequestMessage msg = new(HttpMethod.Get, requestUri); var response = await client.SendAsync(msg); response.StatusCode.ShouldBe(HttpStatusCode.OK); diff --git a/CallbackHandler.Tests/BootstrapperTests.cs b/CallbackHandler.Tests/BootstrapperTests.cs index 6ed62cc..29b2cfd 100644 --- a/CallbackHandler.Tests/BootstrapperTests.cs +++ b/CallbackHandler.Tests/BootstrapperTests.cs @@ -3,6 +3,7 @@ namespace CallbackHandler.Tests { + using CallbackHandler.Bootstrapper; using System.Collections.Generic; using System.Diagnostics; using Lamar; @@ -11,6 +12,7 @@ namespace CallbackHandler.Tests using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Shared.Serialisation; [Collection("TestCollection")] public class BootstrapperTests @@ -24,12 +26,13 @@ public void VerifyBootstrapperIsValid() hostingEnvironment.ApplicationName.Getter().Returns("Test Application"); ServiceRegistry services = new(); - Startup s = new(hostingEnvironment.Instance()); - Startup.Configuration = this.SetupMemoryConfiguration(); + IConfigurationRoot configuration = this.SetupMemoryConfiguration(); - this.AddTestRegistrations(services, hostingEnvironment.Instance()); - s.ConfigureContainer(services); - Startup.Container.AssertConfigurationIsValid(AssertMode.Full); + this.AddTestRegistrations(services, hostingEnvironment.Instance(), configuration); + services.AddCallbackHandlerRegistries(configuration); + Container container = new(services); + container.AssertConfigurationIsValid(AssertMode.Full); + StringSerialiser.Initialise(container.GetRequiredService()); } private IConfigurationRoot SetupMemoryConfiguration() @@ -56,7 +59,8 @@ private IConfigurationRoot SetupMemoryConfiguration() } private void AddTestRegistrations(ServiceRegistry services, - IWebHostEnvironment hostingEnvironment) + IWebHostEnvironment hostingEnvironment, + IConfiguration configuration) { services.AddLogging(); DiagnosticListener diagnosticSource = new(hostingEnvironment.ApplicationName); @@ -64,7 +68,7 @@ private void AddTestRegistrations(ServiceRegistry services, services.AddSingleton(diagnosticSource); services.AddSingleton(hostingEnvironment); services.AddSingleton(hostingEnvironment); - services.AddSingleton(Startup.Configuration); + services.AddSingleton(configuration); } } } diff --git a/CallbackHandler/Bootstrapper/MiddlewareRegistry.cs b/CallbackHandler/Bootstrapper/MiddlewareRegistry.cs index 2d53d52..1f9f9e8 100644 --- a/CallbackHandler/Bootstrapper/MiddlewareRegistry.cs +++ b/CallbackHandler/Bootstrapper/MiddlewareRegistry.cs @@ -2,7 +2,7 @@ using KurrentDB.Client; using Microsoft.Extensions.Logging; using Microsoft.OpenApi; -using Swashbuckle.AspNetCore.SwaggerGen; +using TransactionProcessing.ApiDefaults; namespace CallbackHandler.Bootstrapper; @@ -14,7 +14,6 @@ namespace CallbackHandler.Bootstrapper; using Shared.EventStore.Extensions; using Shared.General; using Shared.Middleware; -using Swashbuckle.AspNetCore.Filters; using System; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -23,17 +22,14 @@ namespace CallbackHandler.Bootstrapper; [ExcludeFromCodeCoverage] public class MiddlewareRegistry :ServiceRegistry { - public MiddlewareRegistry() + public MiddlewareRegistry(IConfiguration configuration) { - String connectionString = Startup.Configuration.GetValue("EventStoreSettings:ConnectionString"); + String connectionString = configuration.GetValue("EventStoreSettings:ConnectionString"); KurrentDBClientSettings eventStoreSettings = KurrentDBClientSettings.Create(connectionString); this.AddHealthChecks().AddEventStore(eventStoreSettings, userCredentials: eventStoreSettings.DefaultCredentials, name: "Eventstore", failureStatus: HealthStatus.Unhealthy, tags: new[] { "db", "eventstore" }); - this.AddSwaggerGen(AddSwaggerAction); - this.AddSwaggerExamples(); - this.AddControllers(); Assembly assembly = this.GetType().GetTypeInfo().Assembly; @@ -47,22 +43,12 @@ public MiddlewareRegistry() this.ConfigureHttpJsonOptions(jsonOptions => JsonSerializerConfiguration.ConfigureMinimalApi(jsonOptions.SerializerOptions)); - this.AddHealthMonitoringRegistration(Startup.Configuration); - } - - private void AddSwaggerAction(SwaggerGenOptions c) { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Callback Handler API", Version = "1.0", Description = "A REST Api to handle callback requests from external parties API's.", Contact = new OpenApiContact { Name = "Stuart Ferguson", Email = "golfhandicapping@btinternet.com" } }); - // add a custom operation filter which sets default values - c.OperationFilter(); - c.ExampleFilters(); - - //Locate the XML files being generated by ASP.NET... - DirectoryInfo directory = new(AppContext.BaseDirectory); - FileInfo[] xmlFiles = directory.GetFiles("*.xml"); + this.AddHealthMonitoringRegistration(configuration); - //... and tell Swagger to use those XML comments. - foreach (FileInfo fileInfo in xmlFiles) { - c.IncludeXmlComments(fileInfo.FullName); - } + this.AddApiDefaults(options => + { + options.Title = "Callback Handler"; + options.Description = "API to accept callbacks from external systems"; + }); } } diff --git a/CallbackHandler/Bootstrapper/RepositoryRegistry.cs b/CallbackHandler/Bootstrapper/RepositoryRegistry.cs index e5d4017..9ae1a54 100644 --- a/CallbackHandler/Bootstrapper/RepositoryRegistry.cs +++ b/CallbackHandler/Bootstrapper/RepositoryRegistry.cs @@ -17,12 +17,12 @@ [ExcludeFromCodeCoverage] public class RepositoryRegistry : ServiceRegistry { - public RepositoryRegistry() + public RepositoryRegistry(IConfiguration configuration) { this.AddTransient(); this.AddSingleton, AggregateRepository>(); - String connectionString = Startup.Configuration.GetValue("EventStoreSettings:ConnectionString"); + String connectionString = configuration.GetValue("EventStoreSettings:ConnectionString"); this.AddKurrentDBProjectionManagementClient(connectionString); this.AddKurrentDBPersistentSubscriptionsClient(connectionString); @@ -31,4 +31,4 @@ public RepositoryRegistry() this.AddSingleton, DomainEventFactory>(); } -} \ No newline at end of file +} diff --git a/CallbackHandler/Bootstrapper/ServiceRegistryExtensions.cs b/CallbackHandler/Bootstrapper/ServiceRegistryExtensions.cs new file mode 100644 index 0000000..b62e3f9 --- /dev/null +++ b/CallbackHandler/Bootstrapper/ServiceRegistryExtensions.cs @@ -0,0 +1,24 @@ +using Lamar; +using Microsoft.Extensions.Configuration; +using Shared.EventStore.Aggregate; +using Shared.General; + +namespace CallbackHandler.Bootstrapper; + +public static class ServiceRegistryExtensions +{ + public static ServiceRegistry AddCallbackHandlerRegistries(this ServiceRegistry services, IConfigurationRoot configuration) + { + ConfigurationReader.Initialise(configuration); + + services.IncludeRegistry(); + services.IncludeRegistry(new RepositoryRegistry(configuration)); + services.IncludeRegistry(new MiddlewareRegistry(configuration)); + services.IncludeRegistry(); + services.IncludeRegistry(); + + TypeProvider.LoadDomainEventsTypeDynamically(); + + return services; + } +} diff --git a/CallbackHandler/CallbackHandler.csproj b/CallbackHandler/CallbackHandler.csproj index c61c8f2..71f01ea 100644 --- a/CallbackHandler/CallbackHandler.csproj +++ b/CallbackHandler/CallbackHandler.csproj @@ -17,14 +17,9 @@ - - - - - - + diff --git a/CallbackHandler/Common/SwaggerDefaultValues.cs b/CallbackHandler/Common/SwaggerDefaultValues.cs deleted file mode 100644 index 95abb4a..0000000 --- a/CallbackHandler/Common/SwaggerDefaultValues.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.OpenApi; - -namespace CallbackHandler.Common -{ - using Microsoft.AspNetCore.Mvc.ApiExplorer; - using Swashbuckle.AspNetCore.SwaggerGen; - using System.Diagnostics.CodeAnalysis; - using System.Linq; - - [ExcludeFromCodeCoverage] - public class SwaggerDefaultValues : IOperationFilter - { - #region Methods - - /// - /// Applies the filter to the specified operation using the given context. - /// - /// The operation to apply the filter to. - /// The current operation filter context. - public void Apply(OpenApiOperation operation, - OperationFilterContext context) - { - ApiDescription apiDescription = context.ApiDescription; - - if (operation.Parameters == null) - { - return; - } - - foreach (OpenApiParameter parameter in operation.Parameters) - { - ApiParameterDescription description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); - - if (parameter.Description == null) - { - parameter.Description = description.ModelMetadata?.Description; - } - - parameter.Required |= description.IsRequired; - } - } - - #endregion - } -} \ No newline at end of file diff --git a/CallbackHandler/Endpoints/CallbackEndpoints.cs b/CallbackHandler/Endpoints/CallbackEndpoints.cs index 70227f3..72c81d7 100644 --- a/CallbackHandler/Endpoints/CallbackEndpoints.cs +++ b/CallbackHandler/Endpoints/CallbackEndpoints.cs @@ -1,30 +1,49 @@ -using CallbackHandler.DataTransferObjects; +using Asp.Versioning; +using Asp.Versioning.Builder; +using CallbackHandler.DataTransferObjects; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Shared.Extensions; using Shared.Middleware; namespace CallbackHandler.Endpoints { public static class CallbackEndpoints { - private const string BaseRoute = "/api/callbacks"; - public static IEndpointRouteBuilder MapCallbackEndpoints(this IEndpointRouteBuilder endpoints) { - RouteGroupBuilder group = endpoints.MapGroup(BaseRoute) - .WithTags("Callbacks"); + IVersionedEndpointRouteBuilder api = endpoints.NewVersionedApi("Callbacks"); + RouteGroupBuilder group = api + .MapGroup("/api/v{version:apiVersion}/callbacks") + .WithTags("Callbacks") + .HasApiVersion(new ApiVersion(1.0)); group.MapPost("/", Handlers.CallbackHandlers.RecordCallback) .WithName("RecordCallback") - .WithSummary("Records a deposit callback") - .WithStandardProduces(); + .WithSummary("Receive a deposit callback") + .WithDescription("Accepts a deposit notification from the transaction processor, records it, and returns the identifier assigned to the callback. Use that identifier with GetCallback to retrieve the recorded message.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status500InternalServerError) + .Produces(StatusCodes.Status501NotImplemented); group.MapGet("/{callbackId:guid}", Handlers.CallbackHandlers.GetCallback) .WithName("GetCallback") - .WithSummary("Gets a callback by ID") - .WithStandardProduces(); + .WithSummary("Get a callback by ID") + .WithDescription("Returns the stored callback message, its original message type, and its reference for the specified callback identifier.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status500InternalServerError) + .Produces(StatusCodes.Status501NotImplemented); return endpoints; } diff --git a/CallbackHandler/Program.cs b/CallbackHandler/Program.cs index 648bb0f..c489cfe 100644 --- a/CallbackHandler/Program.cs +++ b/CallbackHandler/Program.cs @@ -1,138 +1,148 @@ +using CallbackHandler.Endpoints; +using CallbackHandler.Bootstrapper; +using HealthChecks.UI.Client; +using JasperFx.CommandLine.Descriptions; +using Lamar; +using Lamar.Microsoft.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NLog; +using Sentry; using NLog.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Protocol; +using Shared.Extensions; using Shared.General; using Shared.Logger; using Shared.Middleware; -using System; +using Shared.Serialisation; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using JasperFx.CommandLine.Descriptions; +using System.IO; +using System.Reflection; +using TransactionProcessing.ApiDefaults; -namespace CallbackHandler +namespace CallbackHandler; + +[ExcludeFromCodeCoverage] +public static class Program { - using Lamar.Microsoft.DependencyInjection; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Options; - using NLog; - using Sentry.Extensibility; - using Shared.DomainDrivenDesign.EventSourcing; - using Shared.EventStore.Aggregate; - using System.Diagnostics.CodeAnalysis; - using System.IO; - using System.Reflection; - - [ExcludeFromCodeCoverage] - public class Program + public static void Main(string[] args) { - public static void Main(string[] args) - { - Program.CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) - { - //At this stage, we only need our hosting file for ip and ports + FileInfo assemblyFile = new(typeof(Program).Assembly.Location); + string basePath = assemblyFile.Directory!.FullName; - FileInfo fi = new(typeof(Program).Assembly.Location); + ConfigureLogging(); - IConfigurationRoot config = new ConfigurationBuilder().SetBasePath(fi.Directory.FullName) - .AddJsonFile("hosting.json", optional: true) - .AddJsonFile("hosting.development.json", optional: true) - .AddEnvironmentVariables() - .AddInMemoryCollection(new Dictionary - { - ["HealthMonitoring:Service:Version"] = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0" - }).Build(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + builder.Host.UseWindowsService(); + builder.Host.UseLamar(); - ConfigureLogging(); + builder.Logging.AddConsole(); + builder.Logging.AddNLog(); + IConfigurationRoot hostConfiguration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("hosting.json", optional: true) + .AddJsonFile("hosting.development.json", optional: true) + .AddEnvironmentVariables() + .AddInMemoryCollection(new Dictionary + { + ["HealthMonitoring:Service:Version"] = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0" + }) + .Build(); + + builder.Configuration.AddConfiguration(hostConfiguration); + builder.Configuration.SetBasePath(basePath) + .AddJsonFile("hosting.json", optional: true) + .AddJsonFile($"hosting.{builder.Environment.EnvironmentName}.json", optional: true) + .AddJsonFile("/home/txnproc/config/appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"/home/txnproc/config/appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddJsonFile("/home/txnproc/config/appsettings.local.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(); + + ConfigurationReader.Initialise(builder.Configuration); + ConfigureSentry(builder); + + builder.Host.ConfigureContainer((context, services) => + { + services.AddCallbackHandlerRegistries((IConfigurationRoot)context.Configuration); + }); - IHostBuilder hostBuilder = Host.CreateDefaultBuilder(args); - hostBuilder.UseWindowsService(); - hostBuilder.UseLamar(); - hostBuilder.ConfigureLogging(logging => - { - logging.AddConsole(); - logging.AddNLog(); + WebApplication app = builder.Build(); - }); - ConfigureWebHost(hostBuilder, fi.Directory.FullName, config); + Microsoft.Extensions.Logging.ILogger logger = app.Services.GetRequiredService().CreateLogger("CallbackHandler"); + Shared.Logger.Logger.Initialise(logger); + IConfigurationRoot appConfiguration = (IConfigurationRoot)app.Configuration; + appConfiguration.LogConfiguration(Shared.Logger.Logger.LogWarning); + ConfigurationReader.Initialise(appConfiguration); - return hostBuilder; + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); } - private static void ConfigureWebHost(IHostBuilder hostBuilder, - String basePath, - IConfigurationRoot config) { - hostBuilder.ConfigureWebHostDefaults(webBuilder => - { - // Configure per-environment configuration and build a snapshot so we can read settings here. - webBuilder.ConfigureAppConfiguration((context, configBuilder) => - { - IWebHostEnvironment env = context.HostingEnvironment; - - configBuilder.SetBasePath(basePath) - .AddJsonFile("hosting.json", optional: true) - .AddJsonFile($"hosting.{env.EnvironmentName}.json", optional: true) - .AddJsonFile("/home/txnproc/config/appsettings.json", optional: true, reloadOnChange: true) - .AddJsonFile($"/home/txnproc/config/appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) - .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true) - .AddJsonFile($"/home/txnproc/config/appsettings.local.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(); - - Startup.Configuration = configBuilder.Build(); - ConfigurationReader.Initialise(Startup.Configuration); - - // Configure Sentry on the webBuilder using the config snapshot. - ConfigureSentry(env, webBuilder); - }); - - webBuilder.UseStartup(); - webBuilder.UseConfiguration(config); - webBuilder.UseKestrel(); - }); - } + app.UseMiddleware(); + app.AddRequestResponseLogging(); + app.AddExceptionHandler(); + app.UseRouting(); - private static void ConfigureLogging() { - String contentRoot = Directory.GetCurrentDirectory(); - String nlogConfigPath = Path.Combine(contentRoot, "nlog.config"); + app.MapCallbackEndpoints(); + app.MapHealthChecks("health", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = Shared.HealthChecks.HealthCheckMiddleware.WriteResponse + }); + app.MapHealthChecks("healthui", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }); + app.UseApiDefaults(); + + StringSerialiser.Initialise(app.Services.GetRequiredService()); + + app.Run(); + } + + private static void ConfigureLogging() + { + string nlogConfigPath = Path.Combine(Directory.GetCurrentDirectory(), "nlog.config"); - LogManager.Setup(b => + LogManager.Setup(b => + { + b.SetupLogFactory(setup => { - b.SetupLogFactory(setup => - { - setup.AddCallSiteHiddenAssembly(typeof(NlogLogger).Assembly); - setup.AddCallSiteHiddenAssembly(typeof(Shared.Logger.Logger).Assembly); - setup.AddCallSiteHiddenAssembly(typeof(TenantMiddleware).Assembly); - }); - b.LoadConfigurationFromFile(nlogConfigPath); + setup.AddCallSiteHiddenAssembly(typeof(NlogLogger).Assembly); + setup.AddCallSiteHiddenAssembly(typeof(Shared.Logger.Logger).Assembly); + setup.AddCallSiteHiddenAssembly(typeof(TenantMiddleware).Assembly); }); - } + b.LoadConfigurationFromFile(nlogConfigPath); + }); + } - private static void ConfigureSentry(IWebHostEnvironment env, - IWebHostBuilder webBuilder) { - var sentrySection = Startup.Configuration.GetSection("SentryConfiguration"); - if (sentrySection.Exists()) + private static void ConfigureSentry(WebApplicationBuilder builder) + { + IConfiguration configuration = builder.Configuration; + IConfigurationSection sentrySection = configuration.GetSection("SentryConfiguration"); + if (sentrySection.Exists() && !builder.Environment.IsDevelopment()) + { + builder.WebHost.UseSentry(options => { - // Replace the condition below if you intended to only enable Sentry in certain environments. - if (env.IsDevelopment() == false) - { - webBuilder.UseSentry(o => - { - o.Dsn = Startup.Configuration["SentryConfiguration:Dsn"]; - o.SendDefaultPii = true; - o.MaxRequestBodySize = RequestSize.Always; - o.CaptureBlockingCalls = ConfigurationReader.GetValueOrDefault("SentryConfiguration", "CaptureBlockingCalls", false); - o.IncludeActivityData = ConfigurationReader.GetValueOrDefault("SentryConfiguration", "IncludeActivityData", false); - o.Release = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; - }); - } - } + options.Dsn = configuration["SentryConfiguration:Dsn"]; + options.SendDefaultPii = true; + options.MaxRequestBodySize = RequestSize.Always; + options.CaptureBlockingCalls = ConfigurationReader.GetValueOrDefault("SentryConfiguration", "CaptureBlockingCalls", false); + options.IncludeActivityData = ConfigurationReader.GetValueOrDefault("SentryConfiguration", "IncludeActivityData", false); + options.Release = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + }); } } } diff --git a/CallbackHandler/Startup.cs b/CallbackHandler/Startup.cs deleted file mode 100644 index 4c91782..0000000 --- a/CallbackHandler/Startup.cs +++ /dev/null @@ -1,112 +0,0 @@ -using CallbackHandler.Endpoints; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; - -namespace CallbackHandler -{ - using Bootstrapper; - using HealthChecks.UI.Client; - using Lamar; - using Microsoft.AspNetCore.Diagnostics.HealthChecks; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using Sentry; - using Shared.EventStore.Aggregate; - using Shared.Extensions; - using Shared.General; - using Shared.Logger; - using Shared.Middleware; - using Shared.Serialisation; - using System.Diagnostics.CodeAnalysis; - using ILogger = Microsoft.Extensions.Logging.ILogger; - - [ExcludeFromCodeCoverage] - public class Startup - { - public Startup(IWebHostEnvironment webHostEnvironment) - { - Startup.WebHostEnvironment = webHostEnvironment; - } - - /// - /// Gets or sets the configuration. - /// - /// - /// The configuration. - /// - public static IConfigurationRoot Configuration { get; set; } - - /// - /// Gets or sets the web host environment. - /// - /// - /// The web host environment. - /// - public static IWebHostEnvironment WebHostEnvironment { get; set; } - - public static Container Container; - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureContainer(ServiceRegistry services) - { - ConfigurationReader.Initialise(Startup.Configuration); - - services.IncludeRegistry(); - services.IncludeRegistry(); - services.IncludeRegistry(); - services.IncludeRegistry(); - services.IncludeRegistry(); - - TypeProvider.LoadDomainEventsTypeDynamically(); - - Startup.Container = new Container(services); - var serialiser = Container.GetRequiredService(); - StringSerialiser.Initialise(serialiser); - } - - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory, - IHostApplicationLifetime lifetime, IHost host) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - ILogger logger = loggerFactory.CreateLogger("CallbackHandler"); - - Logger.Initialise(logger); - Startup.Configuration.LogConfiguration(Logger.LogWarning); - - ConfigurationReader.Initialise(Startup.Configuration); - app.UseMiddleware(); - app.AddRequestResponseLogging(); - app.AddExceptionHandler(); - - app.UseRouting(); - - app.UseEndpoints(endpoints => - { - endpoints.MapCallbackEndpoints(); - - endpoints.MapHealthChecks("health", - new HealthCheckOptions - { - Predicate = _ => true, - ResponseWriter = Shared.HealthChecks.HealthCheckMiddleware.WriteResponse - }); - endpoints.MapHealthChecks("healthui", - new HealthCheckOptions - { - Predicate = _ => true, - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse - }); - }); - app.UseSwagger(); - - app.UseSwaggerUI(); - } - } -} diff --git a/Directory.Packages.props b/Directory.Packages.props index 111d65f..78a37a9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,16 +3,18 @@ true - + + - - + + + @@ -23,27 +25,21 @@ - - - - - - + + + + + + - - + + - + - - - - - - diff --git a/docs/superpowers/plans/2026-10-20-modern-hosting.md b/docs/superpowers/plans/2026-10-20-modern-hosting.md new file mode 100644 index 0000000..30325c9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-modern-hosting.md @@ -0,0 +1,71 @@ +# Modern Hosting Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate CallbackHandler to ASP.NET Core minimal hosting while preserving existing host, service, middleware, and endpoint behavior. + +**Architecture:** `Program.cs` becomes the composition root for `WebApplicationBuilder` and `WebApplication`. Lamar registries receive configuration explicitly; the legacy `Startup` class and its static configuration handoff are removed after production and test callers migrate. + +**Tech Stack:** .NET 10, ASP.NET Core minimal hosting, Lamar, NLog, Sentry, existing Shared and TransactionProcessing.ApiDefaults packages. + +**Spec:** `docs/superpowers/specs/2026-10-20-modern-hosting-design.md` + +## Global Constraints + +- Preserve current configuration file sources and their ordering. +- Preserve Windows Service, Lamar, Kestrel, NLog, Sentry conditions/options, middleware, and endpoint paths/response writers. +- Do not upgrade unrelated dependencies. +- Do not add or run tests unless requested by the user; perform the requested review/build verification only when authorized. + +## Review Focus + +- Missing optional configuration files must remain optional and environment-specific configuration must still load. +- Sentry must remain disabled in Development and enabled only when the Sentry configuration section exists. +- Configuration must be available before EventStore and health monitoring registries are constructed. +- Middleware must run before mapped endpoint handling, and callback and health routes must retain their current paths and writers. +- `UseApiDefaults` must be applied to the built `WebApplication` so version discovery and OpenAPI/Scalar routes are registered. + +--- + +### Task 1: Modernize the host composition root + +**Files:** +- Modify: `CallbackHandler/Program.cs` +- Test/update: `CallbackHandler.Tests/BootstrapperTests.cs` as required by changed composition API + +**Interfaces:** +- Produces: a `WebApplication` host configured with the existing sources, host integrations, services, and application pipeline. + +- [x] Create the modern builder with the existing args and configure hosting, configuration sources, logging, Windows Service, Kestrel, Lamar, and Sentry from the design spec. +- [x] Move the registrations currently supplied by `Startup.ConfigureContainer` into Lamar's supported modern builder configuration. +- [x] Build the app, initialize logger/configuration, preserve Development exception behavior and middleware, map callback and health routes, invoke `UseApiDefaults`, then run. +- [x] Remove `UseStartup()` and obsolete host-builder helpers after their responsibilities move. + +### Task 2: Remove static Startup configuration from registries + +**Files:** +- Modify: `CallbackHandler/Bootstrapper/MiddlewareRegistry.cs` +- Modify: `CallbackHandler/Bootstrapper/RepositoryRegistry.cs` +- Modify: `CallbackHandler/Startup.cs` (remove after remaining references are migrated) +- Modify: `CallbackHandler.Tests/BootstrapperTests.cs` +- Modify: `CallbackHandler.BusinessLogic.Tests/Mediator/MediatorTests.cs` + +**Interfaces:** +- Consumes: the modern Lamar composition established in Task 1. +- Produces: registrations initialized with configuration supplied through the new composition path, with no production registry reads from `Startup.Configuration`. + +- [x] Update registry construction/configuration to use the explicit configuration path supported by the installed Lamar integration. +- [x] Update tests and any remaining production references to the removed Startup configuration/container members. +- [x] Delete `Startup.cs` when no callers remain. + +### Task 3: Verify migration behavior + +**Files:** +- No planned source changes unless verification exposes a defect. + +**Interfaces:** +- Consumes: completed host and registry migration. + +- [x] Build `CallbackHandler/CallbackHandler.csproj` and inspect diagnostics. +- [ ] Run relevant bootstrapper and integration tests if authorized; verify callback, health, OpenAPI, and Scalar routes and preserved Sentry/configuration behavior. +- [ ] Review the final diff for endpoint ordering, retained configuration sources, and stale `Startup` references. diff --git a/docs/superpowers/specs/2026-10-20-modern-hosting-design.md b/docs/superpowers/specs/2026-10-20-modern-hosting-design.md new file mode 100644 index 0000000..b6c4bcb --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-modern-hosting-design.md @@ -0,0 +1,43 @@ +# Modern Hosting Migration Design + +## Goal + +Move CallbackHandler from the `IHostBuilder` plus `Startup` model to ASP.NET Core minimal hosting so WebApplication-only endpoint extensions such as `UseApiDefaults` can be used directly. + +## Agreed scope + +- Replace `Host.CreateDefaultBuilder` and `UseStartup()` with `WebApplication.CreateBuilder` in `Program.cs`. +- Preserve current hosting configuration sources, Windows service integration, Kestrel, logging, and conditional Sentry configuration. +- Move current service registrations and HTTP pipeline behavior into the modern builder and `WebApplication` pipeline. +- Keep Lamar and its service registries, while eliminating production service-registration dependencies on `Startup.Configuration`. +- Register callback, health check, OpenAPI, and Scalar endpoints in the modern pipeline. Use `UseApiDefaults` on the built `WebApplication`. +- Update tests and other call sites that depend on `Startup.ConfigureContainer` or `Startup.Configuration`. + +## Existing behavior to preserve + +- Load `hosting.json`, environment-specific hosting configuration, the `/home/txnproc/config/appsettings*.json` files, local `appsettings*.json`, and environment variables in the existing order. +- Set `HealthMonitoring:Service:Version` from the entry assembly version, falling back to `0.0.0.0`. +- Use Windows Service integration, Lamar, NLog plus console logging, and Kestrel. +- Enable Sentry only when its configuration section exists and the environment is not Development; preserve its existing options. +- In Development, use the developer exception page. +- Initialize logger and `ConfigurationReader`; preserve tenant, request/response logging, and exception middleware behavior. +- Map callback endpoints and both existing health check routes with their current response writers. +- Preserve service registries and the existing test composition behavior where practical. + +## Design + +`Program.cs` becomes the composition root. It creates the `WebApplicationBuilder`, applies the app's existing configuration and host settings, configures Lamar registries, builds the app, initializes runtime logging/configuration, adds middleware, maps callback and health endpoints, calls `UseApiDefaults`, and runs the app. + +Configuration-dependent registry constructors will receive configuration explicitly rather than reading mutable static state from `Startup`. The registry inclusion mechanism will follow the supported Lamar integration API available in the repository's package versions. `Startup.cs` will be removed once its responsibilities and references have moved. Test fixtures will compose registrations through the replacement API. + +Endpoint registration will retain routing semantics: middleware is installed before endpoint dispatch, and callback, health, OpenAPI, and Scalar routes are all mapped on the built application. The migration will avoid changing endpoint paths or response behavior. + +## Validation + +Build the CallbackHandler project and run its existing relevant tests, adjusting tests only where startup composition APIs changed. Confirm the OpenAPI and Scalar routes are registered by `UseApiDefaults` and the callback and health routes remain mapped. Do not change external package versions as part of this migration unless the current versions lack the required modern-hosting API; if that occurs, stop and report the blocker before changing dependencies. + +## Out of scope + +- Replacing Lamar, NLog, Sentry, or the existing middleware and endpoint implementations. +- Changing service configuration semantics, API routes, response formats, or health check behavior. +- Upgrading unrelated dependencies.