diff --git a/README.md b/README.md index b3996c5..a24c7e7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Boilerplate api provides the following features: * Swagger integration * Health endpoint exposed * Diagnostics endpoint +* **Minimal API support** for modern .NET applications For using the full benefit of the library, Create a simple asp.net core project and install *EasyApi.AspNetCore.Bootstrap* nuget package. @@ -34,11 +35,12 @@ Install-Package LittleBlocks.AspNetCore.Bootstrap ``` +## Traditional Usage with Startup Class + In order to achieve all of this functionality you merely need a few lines of code. At this point your *Program.cs* should look like this: ```csharp - public class Program { public static void Main(string[] args) @@ -79,6 +81,45 @@ In order to achieve all of this functionality you merely need a few lines of cod ``` +## Minimal API Usage + +For minimal APIs, you can use the new simplified approach: + +```csharp + +using LittleBlocks.AspNetCore.Bootstrap; + +var builder = WebApplication.CreateBuilder(args); + +// Bootstrap LittleBlocks services +builder.BootstrapLittleBlocks(app => app + .AddConfigSection() + .HandleApplicationException() + .ConfigureCorrelation(m => m.AutoCorrelateRequests()) + .ConfigureHealthChecks(c => + { + c.AddUrlGroup(new Uri("http://www.google.com"), HttpMethod.Get, "google"); + }) + .AddServices((container, config) => + { + container.AddScoped(); + }) +); + +var app = builder.Build(); + +// Configure the pipeline +app.UseLittleBlocksPipeline(); + +// Define your minimal API endpoints +app.MapGet("/api/hello", () => "Hello World!"); + +app.Run(); + +``` + +This provides the same rich feature set including global error handling, logging, health checks, authentication, CORS, and Swagger documentation, but with the simplified minimal API approach. + The project/solution is ready to be running in visual studio or using dotnet cli. More detail information can be found in [wiki](https://github.com/LittleBlocks/LittleBlocks.API/wiki) diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapper.cs b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapper.cs new file mode 100644 index 0000000..3d7cab5 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/MinimalApiBootstrapper.cs @@ -0,0 +1,225 @@ +// 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; + +/// +/// Bootstrapper for minimal APIs that provides the same configuration capabilities as the traditional AppBootstrapper +/// but without requiring a Startup class. +/// +public sealed class MinimalApiBootstrapper : + IBootstrapApplication, + IConfigureContainer, + IAddExtraConfigSection, + IHandleAdditionalException, + ISetDetailsLevel, + IExtendPipeline, + IConfigureRequestCorrelation, + IConfigureAuthentication, + IConfigureHealthChecks, + IConfigureApplicationBootstrapper +{ + private readonly IConfiguration _configuration; + private readonly ConfigurationOptionBuilder _configurationOptionBuilder; + private readonly GlobalErrorHandlerConfigurationBuilder _errorHandlerBuilder; + private readonly List> _pipelineExtenders = + new List>(); + + private readonly IServiceCollection _services; + private readonly IHealthChecksBuilder _healthChecksBuilder; + private readonly AppInfo _appInfo; + + private readonly AuthOptions _authOptions; + private Action _containerFactory; + private Func _requestCorrelationExtender = cop => cop.EnforceCorrelation(); + + public MinimalApiBootstrapper( + IServiceCollection services, + IConfiguration configuration) + { + _services = services ?? throw new ArgumentNullException(nameof(services)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _configurationOptionBuilder = new ConfigurationOptionBuilder(services, _configuration); + _errorHandlerBuilder = new GlobalErrorHandlerConfigurationBuilder(services); + + _errorHandlerBuilder.UseStandardMessage(); + _appInfo = _configuration.GetApplicationInfo(); + _authOptions = _configuration.GetAuthOptions(); + + _healthChecksBuilder = _services.AddHealthChecks(); + } + + public IAddExtraConfigSection AndSection() + where TSection : class, new() + { + _configurationOptionBuilder.And(); + return this; + } + + public IAddExtraConfigSection AndSection(string section) + where TSection : class, new() + { + _configurationOptionBuilder.And(section); + return this; + } + + public IHandleAdditionalException HandleApplicationException() + where TApplicationBaseException : Exception + { + _errorHandlerBuilder.Handle(); + return this; + } + + public void Bootstrap() + { + _configurationOptionBuilder.Build(); + + _services.TryAddSingleton(); + // Don't add IUrlHelper for minimal APIs as it requires MVC ActionContext + _services.TryAddScoped(); + _services.TryAddSingleton(_ => new ArgumentFormatterOptions()); + + _services.AddDatabaseDeveloperPageExceptionFilter(); + _services.AddHttpRequestContext(); + _services.AddGlobalExceptionHandler(_ => _errorHandlerBuilder.UseDefault()); + _services.AddRequestCorrelation(b => _requestCorrelationExtender(b.ExcludeDefaultUrls())); + _services.AddFeatureFlagging(_configuration); + + // For minimal APIs, we don't add the default MVC services automatically + // Users can still add controllers if needed via AddControllers() on the builder + _services.AddDefaultCorsPolicy(); + _services.AddAuthentication(_authOptions); + _services.AddAuthorization(); // Required for minimal APIs + + // Add minimal API specific services for OpenAPI/Swagger + _services.AddEndpointsApiExplorer(); + _services.AddSwaggerGen(); + + _pipelineExtenders.ForEach(e => e(_services, _configuration)); + + _containerFactory(_services, _configuration); + } + + public IAddExtraConfigSection AddConfigSection() + where TSection : class, new() + { + _configurationOptionBuilder.AddSection(); + return this; + } + + public IAddExtraConfigSection AddConfigSection(string section) + where TSection : class, new() + { + _configurationOptionBuilder.AddSection(section); + return this; + } + + public IBootstrapApplication UseContainer(ContainerFactory containerFactory) + where TContainer : class + { + if (containerFactory == null) throw new ArgumentNullException(nameof(containerFactory)); + + _containerFactory = containerFactory.Create; + return this; + } + + public IConfigureRequestCorrelation UseStandardMessage() + { + _errorHandlerBuilder.UseStandardMessage(); + return this; + } + + public IConfigureRequestCorrelation UseUserErrors() + { + _errorHandlerBuilder.UseUserErrors(); + return this; + } + + public IConfigureRequestCorrelation UseDetailedErrors() + { + _errorHandlerBuilder.UseDetailedErrors(); + return this; + } + + public IConfigureAuthentication ConfigureCorrelation( + Func optionsProvider) + { + _requestCorrelationExtender = optionsProvider ?? + throw new ArgumentNullException(nameof(optionsProvider)); + return this; + } + + public IConfigureAuthentication ConfigureCorrelation(Func optionsProvider) + { + if (optionsProvider == null) throw new ArgumentNullException(nameof(optionsProvider)); + _requestCorrelationExtender = r => optionsProvider(r).EnforceCorrelation(); + return this; + } + + public IExtendPipeline Extend(Action pipelineExtender) + { + if (pipelineExtender == null) throw new ArgumentNullException(nameof(pipelineExtender)); + _pipelineExtenders.Add(pipelineExtender); + return this; + } + + public IConfigureHealthChecks ConfigureAuthentication(Action configure) + { + if (configure == null) throw new ArgumentNullException(nameof(configure)); + configure(_authOptions); + return this; + } + + public IExtendPipeline ConfigureHealthChecks(Action configure) + { + if (configure == null) throw new ArgumentNullException(nameof(configure)); + + configure(_healthChecksBuilder); + + return this; + } + + public IHandleAdditionalException AndHandle() + where TThirdPartyBaseException : Exception + { + _errorHandlerBuilder.AndHandle(); + return this; + } + + public IHandleAdditionalException AndHandle( + Func predicate) where TThirdPartyBaseException : Exception + { + _errorHandlerBuilder.AndHandle(predicate); + return this; + } + + public IHandleAdditionalException AndHandle( + Func, IProvideErrorBuilder> + errorBuilderProvider) where TThirdPartyBaseException : Exception + { + _errorHandlerBuilder.AndHandle(errorBuilderProvider); + return this; + } + + public IHandleAdditionalException AndHandle( + Func, IProvideErrorBuilder> + errorBuilderProvider, Func predicate) + where TThirdPartyBaseException : Exception + { + _errorHandlerBuilder.AndHandle(errorBuilderProvider, predicate); + return this; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationBuilderExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..71a7014 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationBuilderExtensions.cs @@ -0,0 +1,43 @@ +// 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; + +/// +/// Extension methods for WebApplicationBuilder to support LittleBlocks minimal API configuration. +/// +public static class WebApplicationBuilderExtensions +{ + /// + /// Bootstraps the application services using LittleBlocks configuration for minimal APIs. + /// + /// The WebApplicationBuilder instance + /// Configuration function for the application bootstrapper + /// The configured WebApplicationBuilder + public static WebApplicationBuilder BootstrapLittleBlocks( + this WebApplicationBuilder builder, + Func appBootstrapperProvider) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(appBootstrapperProvider); + + // Create a type placeholder for the bootstrapper since minimal APIs don't have a startup class + var bootstrapper = appBootstrapperProvider(new MinimalApiBootstrapper(builder.Services, builder.Configuration)); + bootstrapper.Bootstrap(); + + return builder; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationExtensions.cs new file mode 100644 index 0000000..961aa76 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/WebApplicationExtensions.cs @@ -0,0 +1,182 @@ +// 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 . + +using Serilog; + +namespace LittleBlocks.AspNetCore.Bootstrap; + +/// +/// Configuration options for minimal API pipeline. +/// +public sealed class MinimalApiPipelineOptions +{ + public bool EnableStartPage { get; set; } = true; + public Action PreEndpointConfiguration { get; set; } = null; + public Action PostEndpointConfiguration { get; set; } = null; + public Action PostAuthenticationConfiguration { get; set; } = null; +} + +/// +/// Extension methods for WebApplication to support LittleBlocks minimal API pipeline configuration. +/// +public static class WebApplicationExtensions +{ + /// + /// Configures the default LittleBlocks API pipeline for minimal APIs. + /// + /// The WebApplication instance + /// Optional pipeline configuration options + /// The configured WebApplication + public static WebApplication UseLittleBlocksPipeline( + this WebApplication app, + MinimalApiPipelineOptions options = null) + { + ArgumentNullException.ThrowIfNull(app); + + options ??= new MinimalApiPipelineOptions(); + + InitiateFlushOutstandingOperations(app.Lifetime); + ConfigureDefaultPipeline(app, options); + + return app; + } + + /// + /// Configures the default LittleBlocks API pipeline for minimal APIs with custom configuration. + /// + /// The WebApplication instance + /// Action to configure pipeline options + /// The configured WebApplication + public static WebApplication UseLittleBlocksPipeline( + this WebApplication app, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new MinimalApiPipelineOptions(); + configureOptions(options); + + return app.UseLittleBlocksPipeline(options); + } + + private static void InitiateFlushOutstandingOperations(IHostApplicationLifetime lifetime) + { + lifetime.ApplicationStopped.Register(Log.CloseAndFlush); + } + + private static void ConfigureDefaultPipeline(WebApplication app, MinimalApiPipelineOptions options) + { + var appInfo = app.Configuration.GetApplicationInfo(); + var authOptions = app.Configuration.GetAuthOptions(); + + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseMigrationsEndPoint(); + } + else + { + app.UseGlobalExceptionHandler(); + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseStaticFiles(); + app.UseRequestCorrelation(); + app.UseCorrelatedLogs(); + app.UseRouting(); + app.UseCorsWithDefaultPolicy(); + + // Only add authentication middleware if authentication is configured + if (authOptions.AuthenticationMode != AuthenticationMode.None) + { + app.UseAuthentication(); + } + app.UseAuthorization(); + + options.PostAuthenticationConfiguration?.Invoke(); + + app.UseUserIdentityLogging(); + app.UseDiagnostics(); + + // Configure Swagger for minimal APIs + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + // Pre-endpoint configuration + options.PreEndpointConfiguration?.Invoke(); + + // Configure health checks endpoint + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => true, + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, + }); + + // Configure start page if enabled + if (options.EnableStartPage) + { + app.MapGet("/", context => + { + var content = LoadStartPageFromEmbeddedResource(appInfo.Name); + if (string.IsNullOrEmpty(content)) + content = appInfo.Name; + + context.Response.ContentType = "text/html"; + return context.Response.WriteAsync(content); + }); + } + + // Post-endpoint configuration + options.PostEndpointConfiguration?.Invoke(); + + LogResolvedEnvironment(app.Environment, app.Services.GetRequiredService()); + } + + private static string LoadStartPageFromEmbeddedResource(string applicationName) + { + var assembly = typeof(AppBootstrapper<>).Assembly; + var resourceName = assembly.GetManifestResourceNames().FirstOrDefault(s => s.EndsWith("home.html", StringComparison.CurrentCultureIgnoreCase)); + if (string.IsNullOrEmpty(resourceName)) + return null; + + try + { + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + return null; + + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + content = content.Replace("{{application}}", applicationName); + return content; + } + catch (Exception) + { + return null; + } + } + + private static void LogResolvedEnvironment(IHostEnvironment env, ILoggerFactory loggerFactory) + { + var log = loggerFactory.CreateLogger("Startup"); + log.LogInformation($"{nameof(Application)} is started in '{env.EnvironmentName.ToUpper()}' environment ..."); + } +} \ No newline at end of file diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi/LittleBlocks.Sample.MinimalApi.csproj b/src/Samples/LittleBlocks.Sample.MinimalApi/LittleBlocks.Sample.MinimalApi.csproj new file mode 100644 index 0000000..4002daa --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi/LittleBlocks.Sample.MinimalApi.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi/Program.cs b/src/Samples/LittleBlocks.Sample.MinimalApi/Program.cs new file mode 100644 index 0000000..dc29134 --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi/Program.cs @@ -0,0 +1,111 @@ +using LittleBlocks.AspNetCore.Bootstrap; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +// Bootstrap LittleBlocks services +builder.BootstrapLittleBlocks(app => app + .AddConfigSection() + .HandleApplicationException() + .ConfigureCorrelation(m => m.AutoCorrelateRequests()) + .ConfigureHealthChecks(c => + { + c.AddUrlGroup(new Uri("http://www.google.com"), HttpMethod.Get, "google"); + c.AddUrlGroup(new Uri("https://github.com/littleblocks"), HttpMethod.Get, "LittleBlocks"); + }) + .AddServices((container, config) => + { + // Add custom services here + container.AddScoped(); + }) +); + +var app = builder.Build(); + +// Configure the pipeline +app.UseLittleBlocksPipeline(); + +// Define minimal API endpoints +app.MapGet("/api/greeting", (IGreetingService greetingService) => + greetingService.GetGreeting()); + +app.MapGet("/api/greeting/{name}", (string name, IGreetingService greetingService) => + greetingService.GetGreeting(name)); + +app.MapPost("/api/greeting", (GreetingRequest request, IGreetingService greetingService) => + greetingService.CreateGreeting(request)); + +app.Run(); + +// Sample models and services for demonstration +public class AppSettings +{ + public string DefaultGreeting { get; set; } = "Hello"; +} + +public class GreetingRequest +{ + public string Name { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} + +public class GreetingResponse +{ + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} + +public interface IGreetingService +{ + GreetingResponse GetGreeting(); + GreetingResponse GetGreeting(string name); + GreetingResponse CreateGreeting(GreetingRequest request); +} + +public class GreetingService : IGreetingService +{ + private readonly AppSettings _settings; + private readonly ILogger _logger; + + public GreetingService(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + public GreetingResponse GetGreeting() + { + _logger.LogInformation("Getting default greeting"); + return new GreetingResponse + { + Message = $"{_settings.DefaultGreeting}, World!", + Timestamp = DateTime.UtcNow + }; + } + + public GreetingResponse GetGreeting(string name) + { + _logger.LogInformation("Getting greeting for {Name}", name); + return new GreetingResponse + { + Message = $"{_settings.DefaultGreeting}, {name}!", + Timestamp = DateTime.UtcNow + }; + } + + public GreetingResponse CreateGreeting(GreetingRequest request) + { + _logger.LogInformation("Creating custom greeting for {Name}", request.Name); + return new GreetingResponse + { + Message = $"{request.Message}, {request.Name}!", + Timestamp = DateTime.UtcNow + }; + } +} + +public class MyApplicationException : Exception +{ + public MyApplicationException(string message) : base(message) { } + public MyApplicationException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.Development.json b/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.json b/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.json new file mode 100644 index 0000000..a619df7 --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AppSettings": { + "DefaultGreeting": "Hello" + }, + "Application": { + "Name": "LittleBlocks.Sample.MinimalApi", + "Version": "1.0.0", + "Description": "Sample Minimal API using LittleBlocks" + }, + "AuthOptions": { + "AuthenticationMode": "None" + } +}