From 37c9d82865b3cdb2f05bcfee2456a53bd08326f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:25:54 +0000 Subject: [PATCH 1/2] Initial plan From 7597f77d7346fd001123625126fc848337a455a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:49:54 +0000 Subject: [PATCH 2/2] feat: add minimal api bootstrap options Co-authored-by: moattarwork <1560935+moattarwork@users.noreply.github.com> --- ...ocks.AspNetCore.Bootstrap.UnitTests.csproj | 3 + .../MinimalApiBootstrapperExtensionsTests.cs | 107 ++++++++++ .../Usings.cs | 10 +- .../ApiBootstrapperFeatures.cs | 40 ++++ .../ApiPipelineOptions.cs | 1 + .../ApplicationBuilderExtensions.cs | 69 ++++-- .../MinimalApiBootstrapOptions.cs | 59 ++++++ .../MinimalApiBootstrapperExtensions.cs | 197 ++++++++++++++++++ .../Usings.cs | 2 + 9 files changed, 466 insertions(+), 22 deletions(-) create mode 100644 src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/MinimalApiBootstrapperExtensionsTests.cs create mode 100644 src/LittleBlocks.AspNetCore.Bootstrap/ApiBootstrapperFeatures.cs create mode 100644 src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapOptions.cs create mode 100644 src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapperExtensions.cs diff --git a/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/LittleBlocks.AspNetCore.Bootstrap.UnitTests.csproj b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/LittleBlocks.AspNetCore.Bootstrap.UnitTests.csproj index 1e5d055..105c71e 100644 --- a/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/LittleBlocks.AspNetCore.Bootstrap.UnitTests.csproj +++ b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/LittleBlocks.AspNetCore.Bootstrap.UnitTests.csproj @@ -16,6 +16,9 @@ + + + diff --git a/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/MinimalApiBootstrapperExtensionsTests.cs b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/MinimalApiBootstrapperExtensionsTests.cs new file mode 100644 index 0000000..3b46f4d --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/MinimalApiBootstrapperExtensionsTests.cs @@ -0,0 +1,107 @@ +// This software is part of the LittleBlocks framework +// Copyright (C) 2024 LittleBlocks +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace LittleBlocks.AspNetCore.Bootstrap.UnitTests; + +public class MinimalApiBootstrapperExtensionsTests +{ + [Fact] + public void Should_Register_Default_MinimalApi_Services() + { + // Arrange + var builder = CreateBuilder(); + + // Act + var options = builder.BootstrapMinimalApi(); + var provider = builder.Services.BuildServiceProvider(); + + // Assert + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + + var application = provider.GetRequiredService>().Value; + Assert.Equal("TestApp", application.Name); + Assert.Equal("v1", application.Version); + + Assert.Equal(ApiBootstrapperFeatures.MinimalDefaults, options.Features); + } + + [Fact] + public void Should_Respect_Feature_Flags_When_Disabling_Correlation() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.BootstrapMinimalApi(o => + { + o.Features &= ~ApiBootstrapperFeatures.RequestCorrelation; + }); + var provider = builder.Services.BuildServiceProvider(); + + // Assert + Assert.Null(provider.GetService()); + } + + [Fact] + public void Should_Bind_Additional_Configuration_Sections() + { + // Arrange + var builder = CreateBuilder(new Dictionary + { + {"CustomOptions:Enabled", "true"} + }); + + // Act + builder.BootstrapMinimalApi(o => o.AddSection("CustomOptions")); + var provider = builder.Services.BuildServiceProvider(); + + // Assert + var options = provider.GetRequiredService>().Value; + Assert.True(options.Enabled); + } + + private static WebApplicationBuilder CreateBuilder(Dictionary additionalSettings = null) + { + var settings = new Dictionary + { + {"Application:Name", "TestApp"}, + {"Application:Version", "v1"}, + {"Application:Environment:Name", "Development"}, + {"AuthOptions:AuthenticationMode", "None"} + }; + + if (additionalSettings != null) + { + foreach (var pair in additionalSettings) + settings[pair.Key] = pair.Value; + } + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Development, + ContentRootPath = Directory.GetCurrentDirectory() + }); + + builder.Configuration.AddInMemoryCollection(settings); + return builder; + } + + private sealed class CustomOptions + { + public bool Enabled { get; set; } + } +} diff --git a/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/Usings.cs b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/Usings.cs index ae087eb..5c5169d 100644 --- a/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/Usings.cs +++ b/src/LittleBlocks.AspNetCore.Bootstrap.UnitTests/Usings.cs @@ -1,6 +1,14 @@ -global using LittleBlocks.DependencyInjection; +global using LittleBlocks.DependencyInjection; global using LittleBlocks.Http; +global using LittleBlocks.Configurations; +global using LittleBlocks.AspNetCore.Bootstrap; +global using LittleBlocks.AspNetCore.RequestCorrelation.Core; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Hosting; global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Options; global using Xunit; diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/ApiBootstrapperFeatures.cs b/src/LittleBlocks.AspNetCore.Bootstrap/ApiBootstrapperFeatures.cs new file mode 100644 index 0000000..6586ef6 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/ApiBootstrapperFeatures.cs @@ -0,0 +1,40 @@ +// This software is part of the LittleBlocks framework +// Copyright (C) 2024 LittleBlocks +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace LittleBlocks.AspNetCore.Bootstrap; + +[Flags] +public enum ApiBootstrapperFeatures +{ + None = 0, + Configuration = 1 << 0, + RequestCorrelation = 1 << 1, + FeatureFlags = 1 << 2, + Cors = 1 << 3, + Authentication = 1 << 4, + OpenApi = 1 << 5, + HealthChecks = 1 << 6, + ExceptionHandling = 1 << 7, + Diagnostics = 1 << 8, + StaticFiles = 1 << 9, + HttpsRedirection = 1 << 10, + Controllers = 1 << 11, + StartPage = 1 << 12, + All = Configuration | RequestCorrelation | FeatureFlags | Cors | Authentication | OpenApi | HealthChecks | + ExceptionHandling | Diagnostics | StaticFiles | HttpsRedirection | Controllers | StartPage, + MinimalDefaults = All & ~Controllers, + MvcDefaults = All +} diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/ApiPipelineOptions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/ApiPipelineOptions.cs index aeefc31..7994327 100644 --- a/src/LittleBlocks.AspNetCore.Bootstrap/ApiPipelineOptions.cs +++ b/src/LittleBlocks.AspNetCore.Bootstrap/ApiPipelineOptions.cs @@ -29,6 +29,7 @@ public sealed class ApiPipelineOptions( public Action PostEndPointsConfigure { get; } = null; public bool EnableStartPage { get; set; } = true; + public ApiBootstrapperFeatures Features { get; set; } = ApiBootstrapperFeatures.MvcDefaults; public Action StartPageConfigure { get; } = (builder, appInfo) => builder.UseStartPage(appInfo.Name); } diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/ApplicationBuilderExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/ApplicationBuilderExtensions.cs index 006f65e..c3c787b 100644 --- a/src/LittleBlocks.AspNetCore.Bootstrap/ApplicationBuilderExtensions.cs +++ b/src/LittleBlocks.AspNetCore.Bootstrap/ApplicationBuilderExtensions.cs @@ -50,43 +50,70 @@ private static void UseDefaultApiPipeline(this IApplicationBuilder app, ApiPipel var appInfo = options.Configuration.GetApplicationInfo(); var authOptions = options.Configuration.GetAuthOptions(); - if (options.Environment.IsDevelopment()) + if (options.Features.HasFlag(ApiBootstrapperFeatures.ExceptionHandling)) { - app.UseDeveloperExceptionPage(); - app.UseMigrationsEndPoint(); + if (options.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseMigrationsEndPoint(); + } + else + { + app.UseGlobalExceptionHandler(); + app.UseHsts(); + } } - else + + if (options.Features.HasFlag(ApiBootstrapperFeatures.HttpsRedirection)) + app.UseHttpsRedirection(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.StaticFiles)) + app.UseStaticFiles(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.RequestCorrelation)) { - app.UseGlobalExceptionHandler(); - app.UseHsts(); + app.UseRequestCorrelation(); + app.UseCorrelatedLogs(); } - app.UseHttpsRedirection(); - app.UseStaticFiles(); - app.UseRequestCorrelation(); - app.UseCorrelatedLogs(); app.UseRouting(); - app.UseCorsWithDefaultPolicy(); - app.UseAuthentication(); - app.UseAuthorization(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Cors)) + app.UseCorsWithDefaultPolicy(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Authentication)) + { + app.UseAuthentication(); + app.UseAuthorization(); + } options.PostAuthenticationConfigure?.Invoke(); app.UseUserIdentityLogging(); - app.UseDiagnostics(); - app.UseOpenApiDocumentation(appInfo, u => u.ConfigureAuth(appInfo, authOptions.Authentication)); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Diagnostics)) + app.UseDiagnostics(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.OpenApi)) + app.UseOpenApiDocumentation(appInfo, u => u.ConfigureAuth(appInfo, authOptions.Authentication)); + app.UseEndpoints(endpoints => { options.PreEndPointsConfigure?.Invoke(endpoints); - endpoints.MapHealthChecks("/health", new HealthCheckOptions + if (options.Features.HasFlag(ApiBootstrapperFeatures.HealthChecks)) { - Predicate = _ => true, - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, - }); - endpoints.MapControllers(); + endpoints.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, + }); + } + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Controllers)) + endpoints.MapControllers(); - if (options.EnableStartPage) + if (options.Features.HasFlag(ApiBootstrapperFeatures.StartPage) && options.EnableStartPage) options.StartPageConfigure?.Invoke(endpoints, appInfo); options.PostEndPointsConfigure?.Invoke(endpoints); diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapOptions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapOptions.cs new file mode 100644 index 0000000..9bf0cdb --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapOptions.cs @@ -0,0 +1,59 @@ +// This software is part of the LittleBlocks framework +// Copyright (C) 2024 LittleBlocks +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace LittleBlocks.AspNetCore.Bootstrap; + +public sealed class MinimalApiBootstrapOptions +{ + private readonly List> _configurationSections = + new List>(); + + public ApiBootstrapperFeatures Features { get; set; } = ApiBootstrapperFeatures.MinimalDefaults; + + public LevelOfDetails ErrorDetails { get; set; } = LevelOfDetails.StandardMessage; + + public Func CorrelationOptions { get; set; } = + builder => builder.EnforceCorrelation(); + + public Action ConfigureHealthChecks { get; set; } + + public Action ConfigureServices { get; set; } + + public Action ConfigureEndpoints { get; set; } + + public Action PostConfigureEndpoints { get; set; } + + public bool EnableStartPage { get; set; } = true; + + internal IReadOnlyList> ConfigurationSections => _configurationSections; + + internal AppInfo AppInfo { get; set; } + + internal AuthOptions AuthOptions { get; set; } + + public MinimalApiBootstrapOptions AddSection() where TSection : class, new() + { + _configurationSections.Add(builder => builder.AddSection()); + return this; + } + + public MinimalApiBootstrapOptions AddSection(string sectionName) where TSection : class, new() + { + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + _configurationSections.Add(builder => builder.AddSection(sectionName)); + return this; + } +} diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapperExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapperExtensions.cs new file mode 100644 index 0000000..214ba9a --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapperExtensions.cs @@ -0,0 +1,197 @@ +// This software is part of the LittleBlocks framework +// Copyright (C) 2024 LittleBlocks +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace LittleBlocks.AspNetCore.Bootstrap; + +public static class MinimalApiBootstrapperExtensions +{ + public static MinimalApiBootstrapOptions BootstrapMinimalApi(this WebApplicationBuilder builder, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new MinimalApiBootstrapOptions(); + configure?.Invoke(options); + + ApplyServiceConfiguration(builder.Services, builder.Configuration, options); + builder.Services.AddSingleton(options); + + return options; + } + + public static void UseMinimalApiBootstrap(this WebApplication app, MinimalApiBootstrapOptions options = null) + { + ArgumentNullException.ThrowIfNull(app); + + options ??= app.Services.GetService() ?? new MinimalApiBootstrapOptions(); + + options.AppInfo ??= app.Configuration.GetApplicationInfo(); + options.AuthOptions ??= app.Configuration.GetAuthOptions(); + + var loggerFactory = app.Services.GetRequiredService(); + ConfigurePipeline(app, options, loggerFactory); + } + + private static void ApplyServiceConfiguration(IServiceCollection services, IConfiguration configuration, + MinimalApiBootstrapOptions options) + { + var configurationBuilder = new ConfigurationOptionBuilder(services, configuration); + if (options.Features.HasFlag(ApiBootstrapperFeatures.Configuration)) + { + configurationBuilder.Build(); + foreach (var configureSection in options.ConfigurationSections) + configureSection(configurationBuilder); + } + + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(_ => new ArgumentFormatterOptions()); + services.AddDatabaseDeveloperPageExceptionFilter(); + services.AddHttpRequestContext(); + + ConfigureErrorHandling(services, options); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.RequestCorrelation)) + services.AddRequestCorrelation(b => options.CorrelationOptions(b.ExcludeDefaultUrls())); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.FeatureFlags)) + services.AddFeatureFlagging(configuration); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Controllers)) + services.AddControllers().AddNewtonsoftJson(o => o.SerializerSettings.ConfigureJsonSettings()); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Cors)) + services.AddDefaultCorsPolicy(); + + options.AppInfo ??= configuration.GetApplicationInfo(); + options.AuthOptions ??= configuration.GetAuthOptions(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Authentication)) + services.AddAuthentication(options.AuthOptions); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.OpenApi)) + { + services.AddEndpointsApiExplorer(); + services.AddOpenApiDocumentation(options.AppInfo, options.AuthOptions); + } + + if (options.Features.HasFlag(ApiBootstrapperFeatures.HealthChecks)) + { + var healthChecksBuilder = services.AddHealthChecks(); + options.ConfigureHealthChecks?.Invoke(healthChecksBuilder); + } + + options.ConfigureServices?.Invoke(services, configuration); + } + + private static void ConfigureErrorHandling(IServiceCollection services, MinimalApiBootstrapOptions options) + { + if (options.Features.HasFlag(ApiBootstrapperFeatures.ExceptionHandling) == false) + return; + + var errorHandlerBuilder = new GlobalErrorHandlerConfigurationBuilder(services); + + switch (options.ErrorDetails) + { + case LevelOfDetails.UserErrors: + errorHandlerBuilder.UseUserErrors(); + break; + case LevelOfDetails.DetailedErrors: + errorHandlerBuilder.UseDetailedErrors(); + break; + default: + errorHandlerBuilder.UseStandardMessage(); + break; + } + + services.AddGlobalExceptionHandler(_ => errorHandlerBuilder.UseDefault()); + } + + private static void ConfigurePipeline(WebApplication app, MinimalApiBootstrapOptions options, + ILoggerFactory loggerFactory) + { + var appInfo = options.AppInfo ?? app.Configuration.GetApplicationInfo(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.ExceptionHandling)) + { + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseMigrationsEndPoint(); + } + else + { + app.UseGlobalExceptionHandler(); + app.UseHsts(); + } + } + + if (options.Features.HasFlag(ApiBootstrapperFeatures.HttpsRedirection)) + app.UseHttpsRedirection(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.StaticFiles)) + app.UseStaticFiles(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.RequestCorrelation)) + { + app.UseRequestCorrelation(); + app.UseCorrelatedLogs(); + } + + app.UseRouting(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Cors)) + app.UseCorsWithDefaultPolicy(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Authentication)) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + + app.UseUserIdentityLogging(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.Diagnostics)) + app.UseDiagnostics(); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.OpenApi)) + app.UseOpenApiDocumentation(appInfo, ui => ui.ConfigureAuth(appInfo, options.AuthOptions.Authentication)); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.HealthChecks)) + { + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, + }); + } + + options.ConfigureEndpoints?.Invoke(app); + + if (options.Features.HasFlag(ApiBootstrapperFeatures.StartPage) && options.EnableStartPage) + app.UseStartPage(appInfo.Name); + + options.PostConfigureEndpoints?.Invoke(app); + + LogResolvedEnvironment(app.Environment, loggerFactory); + } + + private static void LogResolvedEnvironment(IHostEnvironment env, ILoggerFactory loggerFactory) + { + var log = loggerFactory.CreateLogger("Startup"); + log.LogInformation($"{nameof(Application)} is started in '{env.EnvironmentName.ToUpper()}' environment ..."); + } +} diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Usings.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Usings.cs index 8bd293a..f9d7453 100644 --- a/src/LittleBlocks.AspNetCore.Bootstrap/Usings.cs +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Usings.cs @@ -12,8 +12,10 @@ global using LittleBlocks.AspNetCore.RequestCorrelation.Core.OptionsBuilder; global using LittleBlocks.AspNetCore.Security; global using LittleBlocks.AspNetCore.Security.Fluent; +global using LittleBlocks.AspNetCore.Serializations; global using LittleBlocks.Configurations.Fluents; global using LittleBlocks.ExceptionHandling; +global using LittleBlocks.ExceptionHandling.Domain; global using LittleBlocks.ExceptionHandling.ConfigurationBuilder; global using LittleBlocks.ExceptionHandling.ErrorBuilder.Fluent; global using LittleBlocks.Extensions;