diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ConfigurationExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ConfigurationExtensions.cs new file mode 100644 index 0000000..062c354 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using LittleBlocks.Logging.SeriLog; + +namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; + +public static class ConfigurationExtensions +{ + public static AppInfo GetApplicationInfo(this WebApplicationBuilder builder) + { + var appName = builder.Configuration["Application:Name"] ?? builder.Environment.ApplicationName; + var appVersion = builder.Configuration["Application:Version"] ?? "1.0.0"; + var appDescription = builder.Configuration["Application:Description"] ?? ""; + var appEnvironment = builder.Environment.EnvironmentName; + return new AppInfo(appName, appVersion, appEnvironment, appDescription); + } + + public static LoggingContext GetLoggingContext(this WebApplicationBuilder builder) + { + var applicationInfo = builder.GetApplicationInfo(); + return new LoggingContext(applicationInfo, applicationInfo.ToTags()) + { + Host = "ASPNetCore", + ExcludeEventFilter = "ClientAgent = 'AlwaysOn' or (RequestPath = '/health' and StatusCode < 400)" + }; + } + + public static HostInfo GetHostInfo(this WebApplicationBuilder builder) + { + var hostType = Enum.TryParse(typeof(HostType), builder.Configuration["Host:Type"], out var result) + ? (HostType) result + : HostType.Kestrel; + return new HostInfo(hostType, hostType == HostType.Kestrel); + } + + private static string[] ToTags(this AppInfo appInfo) + { + ArgumentNullException.ThrowIfNull(appInfo); + + return new[] + { + $"app:{appInfo.Name}", + $"version:{appInfo.Version}", + $"env:{appInfo.Environment}" + }; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostInfo.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostInfo.cs new file mode 100644 index 0000000..7dbc525 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostInfo.cs @@ -0,0 +1,3 @@ +namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; + +public record HostInfo(HostType Type, bool RequiredSslRedirect); \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostType.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostType.cs new file mode 100644 index 0000000..e8fa323 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/HostType.cs @@ -0,0 +1,7 @@ +namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; + +public enum HostType +{ + Kestrel, + Docker, +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ServiceCollectionExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..118490b --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using MicroElements.Swashbuckle.FluentValidation.AspNetCore; +using Microsoft.FeatureManagement; +using Microsoft.OpenApi.Models; + +namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCorsWithDefaultPolicy(this IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddCors(c => + c.AddDefaultPolicy(p => p.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin())); + return services; + } + + public static IServiceCollection AddTypeMapping(this IServiceCollection services, + Action configure) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + if (configure == null) throw new ArgumentNullException(nameof(configure)); + + services.AddTransient(sp => new MapperConfiguration(configure).CreateMapper()); + + return services; + } + + public static IServiceCollection AddOpenApiDocumentation(this IServiceCollection services, + AppInfo appInfo) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(appInfo); + + services.AddSwaggerGen(d => + { + d.SwaggerDoc("v1", new OpenApiInfo + { + Title = $"{appInfo.Name} - {appInfo.Environment}", + Version = appInfo.Version, + Description = appInfo.Description + }); + + var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "Ardevora.Feeds.Bloomberg.*.xml", + SearchOption.TopDirectoryOnly).ToList(); + xmlFiles.ForEach(xmlFile => d.IncludeXmlComments(xmlFile)); + }); + services.AddFluentValidationRulesToSwagger(); + + return services; + } + + public static IServiceCollection AddFeatures(this IServiceCollection services, IConfiguration configuration) + { + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + + var name = typeof(T).Name; + services.AddFeatureManagement(configuration.GetSection(name)); + + return services; + } + + public static IServiceCollection Remove(this IServiceCollection services) + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(T)); + services.Remove(descriptor); + + return services; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebApplicationExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..4161718 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,79 @@ +using System.Net; +using GlobalExceptionHandler.WebApi; +using LittleBlocks.Exceptions; +using Newtonsoft.Json; + +namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; + +public static class WebApplicationExtensions +{ + public static void UseGlobalExceptionAndLoggingHandler(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + app.UseGlobalExceptionHandler(x => + { + x.ContentType = "application/json"; + x.ResponseBody(s => JsonConvert.SerializeObject(new + { + Message = "An error occurred whilst processing your request" + })); + x.Map() + .ToStatusCode(HttpStatusCode.InternalServerError) + .WithBody((e, context) => JsonConvert.SerializeObject(new {e.Message})); + + x.OnError((e, context) => + { + app.Logger.LogError((Exception) e, "Error in processing the request to {Path}", context.Request.Path); + return Task.CompletedTask; + }); + }); + } + + public static void UseHttpsRedirection(this WebApplication app, HostInfo hostInfo) + { + ArgumentNullException.ThrowIfNull(hostInfo); + if (hostInfo.RequiredSslRedirect) + app.UseHttpsRedirection(); + } + + public static void UseOpenApiDocumentation(this WebApplication app, AppInfo appInfo) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(appInfo); + + var name = $"{appInfo.Name} v{appInfo.Version}"; + + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", name); + c.DocumentTitle = name; + }); + + app.UseReDoc(c => + { + c.DocumentTitle = name; + c.SpecUrl("/swagger/v1/swagger.json"); + }); + } + + public static void MapDependencyHealthChecks(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapHealthChecks("/health", + new HealthCheckOptions {ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse}); + } + + public static IServiceCollection AddDependencyHealthChecks(this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var builder = services.AddHealthChecks(); + configure?.Invoke(builder); + + return services; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebHostBuilderExtensions.cs b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebHostBuilderExtensions.cs new file mode 100644 index 0000000..3aed3b3 --- /dev/null +++ b/src/LittleBlocks.AspNetCore.Bootstrap/Extensions/WebHostBuilderExtensions.cs @@ -0,0 +1,15 @@ +// namespace LittleBlocks.AspNetCore.Bootstrap.Extensions; +// +// public static class WebHostBuilderExtensions +// { +// public static void ConfigureAzureKeyVault(this IWebHostBuilder builder, +// Action? configure = null) +// { +// if (builder == null) throw new ArgumentNullException(nameof(builder)); +// +// var options = new AzureKeyVaultOptions(); +// configure?.Invoke(options); +// +// builder.ConfigureAppConfiguration((ctx, c) => { c.ConfigureAzureKeyVault(options); }); +// } +// } \ No newline at end of file diff --git a/src/LittleBlocks.AspNetCore/LittleBlocks.AspNetCore.csproj b/src/LittleBlocks.AspNetCore/LittleBlocks.AspNetCore.csproj index db99c71..d418262 100644 --- a/src/LittleBlocks.AspNetCore/LittleBlocks.AspNetCore.csproj +++ b/src/LittleBlocks.AspNetCore/LittleBlocks.AspNetCore.csproj @@ -34,7 +34,11 @@ + + + + diff --git a/src/LittleBlocks.AspNetCore/RequestCorrelation/CorrelationExtensions.cs b/src/LittleBlocks.AspNetCore/RequestCorrelation/CorrelationExtensions.cs new file mode 100644 index 0000000..5fb599f --- /dev/null +++ b/src/LittleBlocks.AspNetCore/RequestCorrelation/CorrelationExtensions.cs @@ -0,0 +1,28 @@ +using CorrelationId; +using CorrelationId.DependencyInjection; + +namespace LittleBlocks.AspNetCore.RequestCorrelation; + +public static class CorrelationExtensions +{ + public static IServiceCollection AddRequestCorrelation(this IServiceCollection service, + Action configure = null) + { + return service.AddDefaultCorrelationId(options => + { + options.CorrelationIdGenerator = () => Guid.NewGuid().ToString(); + options.AddToLoggingScope = true; + options.EnforceHeader = false; + options.IgnoreRequestHeader = false; + options.IncludeInResponse = true; + options.UpdateTraceIdentifier = true; + + configure?.Invoke(options); + }); + } + + public static void UseRequestCorrelation(this WebApplication app) + { + app.UseCorrelationId(); + } +} \ No newline at end of file diff --git a/src/LittleBlocks.Configurations/AppInfo.cs b/src/LittleBlocks.Configurations/AppInfo.cs index e0175a7..a0dda99 100644 --- a/src/LittleBlocks.Configurations/AppInfo.cs +++ b/src/LittleBlocks.Configurations/AppInfo.cs @@ -16,16 +16,10 @@ namespace LittleBlocks.Configurations; -public sealed class AppInfo +public sealed class AppInfo(string name, string version, string environment, string description = "") { - public AppInfo(string name, string version, string environment) - { - Name = name; - Version = version; - Environment = environment; - } - - public string Name { get; } - public string Version { get; } - public string Environment { get; } + public string Name { get; } = name; + public string Version { get; } = version; + public string Environment { get; } = environment; + public string Description { get; } = description; } diff --git a/src/LittleBlocks.Logging.SeriLog/LittleBlocks.Logging.SeriLog.csproj b/src/LittleBlocks.Logging.SeriLog/LittleBlocks.Logging.SeriLog.csproj index 2cfa1a3..9ca139c 100644 --- a/src/LittleBlocks.Logging.SeriLog/LittleBlocks.Logging.SeriLog.csproj +++ b/src/LittleBlocks.Logging.SeriLog/LittleBlocks.Logging.SeriLog.csproj @@ -15,6 +15,7 @@ + diff --git a/src/LittleBlocks.Logging.SeriLog/LoggingContext.cs b/src/LittleBlocks.Logging.SeriLog/LoggingContext.cs new file mode 100644 index 0000000..d020094 --- /dev/null +++ b/src/LittleBlocks.Logging.SeriLog/LoggingContext.cs @@ -0,0 +1,18 @@ +using LittleBlocks.Configurations; +using Microsoft.Extensions.Hosting; + +namespace LittleBlocks.Logging.SeriLog; + +public record LoggingContext(AppInfo AppInfo, string[] Tags) +{ + public string Host { get; init; } = ""; + public string ExcludeEventFilter { get; init; } = ""; + public bool EnableDebugging { get; init; } = false; + public string DefaultFilePath { get; init; } = @"logs\log.txt"; + public LogEventLevel DefaultLogLevel { get; init; } = LogEventLevel.Information; + + public bool IsDevelopment() + { + return AppInfo?.Environment == Environments.Development; + } +} \ No newline at end of file diff --git a/src/LittleBlocks.Logging/LittleBlocks.Logging.csproj b/src/LittleBlocks.Logging/LittleBlocks.Logging.csproj index ac21342..ed8937d 100644 --- a/src/LittleBlocks.Logging/LittleBlocks.Logging.csproj +++ b/src/LittleBlocks.Logging/LittleBlocks.Logging.csproj @@ -16,9 +16,11 @@ + + diff --git a/src/LittleBlocks/Exceptions/AppException.cs b/src/LittleBlocks/Exceptions/AppException.cs new file mode 100644 index 0000000..ca2b559 --- /dev/null +++ b/src/LittleBlocks/Exceptions/AppException.cs @@ -0,0 +1,12 @@ +namespace LittleBlocks.Exceptions; + +public class AppException : Exception +{ + public AppException(string message) : base(message) + { + } + + public AppException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Controllers/ValuesController.cs b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Controllers/ValuesController.cs deleted file mode 100644 index 19a19a5..0000000 --- a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Controllers/ValuesController.cs +++ /dev/null @@ -1,53 +0,0 @@ -// 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.Sample.Minimal.WebAPI.Controllers; - -[Route("api/[controller]")] -public class ValuesController : Controller -{ - // GET api/values - [HttpGet] - public IEnumerable Get() - { - return new[] {"value1", "value2"}; - } - - // GET api/values/5 - [HttpGet("{id}")] - public string Get(int id) - { - return "value"; - } - - // POST api/values - [HttpPost] - public void Post([FromBody] string value) - { - } - - // PUT api/values/5 - [HttpPut("{id}")] - public void Put(int id, [FromBody] string value) - { - } - - // DELETE api/values/5 - [HttpDelete("{id}")] - public void Delete(int id) - { - } -} diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Extensions/OneOfExtensions.cs b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Extensions/OneOfExtensions.cs new file mode 100644 index 0000000..a09b4c3 --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Extensions/OneOfExtensions.cs @@ -0,0 +1,19 @@ +using OneOf; + +namespace LittleBlocks.Sample.Minimal.WebAPI.Extensions; + +public static class OneOfExtensions +{ + public static IResult MapResult(this OneOf oneOf, HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return oneOf.Match( + TypedResults.Ok, + error => + { + context.Features.Set(error.Message); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); + }); + } +} \ No newline at end of file diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/LittleBlocks.Sample.Minimal.WebAPI.csproj b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/LittleBlocks.Sample.Minimal.WebAPI.csproj index 7fa924e..3f8a3f8 100644 --- a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/LittleBlocks.Sample.Minimal.WebAPI.csproj +++ b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/LittleBlocks.Sample.Minimal.WebAPI.csproj @@ -8,6 +8,13 @@ Linux + + + + + + + diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Program.cs b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Program.cs index 1c544d3..68aa879 100644 --- a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Program.cs +++ b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Program.cs @@ -15,15 +15,94 @@ // along with this program. If not, see . using System.Text.Json.Serialization; -using AutoMapper.Features; +using Asp.Versioning; using FluentValidation.AspNetCore; -using LittleBlocks.AspNetCore.Documentation; using LittleBlocks.AspNetCore.RequestCorrelation; -using LittleBlocks.Sample.Minimal.WebAPI.Extensions; +using LittleBlocks.Configurations; +using LittleBlocks.Logging.SeriLog; +using Microsoft.EntityFrameworkCore.Migrations.Operations.Builders; using Serilog; namespace LittleBlocks.Sample.Minimal.WebAPI; +public class WebApp +{ + public static IWebAppBuilder CreateBuilder(string[] args, Action webAppOptions) + { + + } +} + +public class WebAppOptions(AppInfo appInfo, HostInfo hostInfo, LoggingContext loggingContext) +{ + public AppInfo AppInfo { get; } = appInfo; + public HostInfo HostInfo { get; } = hostInfo; + public LoggingContext LoggingContext { get; } = loggingContext; + + public Action ConfigureLogging { get; set; } = h => + h.UseSerilog((context, configuration) => (context, configuration).ConfigureSerilog(loggingContext)); + + public Action ConfigureJson { get; set; } = o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + + public Action ConfigureApiVersioning { get; set; } = o => + { + o.ReportApiVersions = true; + o.ApiVersionReader = new MediaTypeApiVersionReader(); + o.AssumeDefaultVersionWhenUnspecified = true; + o.ApiVersionSelector = new CurrentImplementationApiVersionSelector(o); + }; + + public Action ConfigureDependencyHealthCheck { get; set; } = o => + { + o.ReportApiVersions = true; + o.ApiVersionReader = new MediaTypeApiVersionReader(); + o.AssumeDefaultVersionWhenUnspecified = true; + o.ApiVersionSelector = new CurrentImplementationApiVersionSelector(o); + }; + + public Action ConfigureApiDocumentation { get; set; } = (services, appInfo) => + { + services.AddEndpointsApiExplorer(); + services.AddOpenApiDocumentation(appInfo); + }; +} + +public interface IWebAppBuilder +{ + WebApplication Build(); +} + +public sealed class WebAppBuilder(string[] args, Action configure) : IWebAppBuilder +{ + public WebApplication Build() + { + var builder = WebApplication.CreateBuilder(args); + + var appInfo = builder.GetApplicationInfo(); + var hostInfo = builder.GetHostInfo(); + var loggingContext = builder.GetLoggingContext(); + + var options = new WebAppOptions(appInfo, hostInfo, loggingContext); + + configure(options); + + options.ConfigureLogging(builder.Host); + + builder.Services.AddControllers() + .AddJsonOptions(o => options.ConfigureJson(o)) + .AddFluentValidationAutoValidation(); + + builder.Services.AddValidatorsFromAssemblyContaining<>(); + + options.ConfigureApiVersioning() + + options.ConfigureLogging(builder.Host); + } +} + +var appx = WebApp.CreateBuilder(args, o => { }).Build(); + + var builder = WebApplication.CreateBuilder(args); var applicationInfo = builder.GetApplicationInfo(); @@ -46,12 +125,10 @@ namespace LittleBlocks.Sample.Minimal.WebAPI; }); builder.Services.AddDependencyHealthChecks(); -builder.Services.AddFeatures>(builder.Configuration); builder.Services .AddSingleton(applicationInfo) .AddRouting(o => o.LowercaseUrls = true) - .AddMediatR(typeof(Program)) .AddEndpointsApiExplorer() .AddOpenApiDocumentation(applicationInfo) .AddRequestCorrelation() @@ -59,6 +136,11 @@ namespace LittleBlocks.Sample.Minimal.WebAPI; .AddTypeMapping(c => c.AddProfile()); var app = builder.Build(); +appx.RunAsync(o => +{ + +}); + app.UseRequestCorrelation(); app.UseOpenApiDocumentation(applicationInfo); @@ -78,7 +160,7 @@ namespace LittleBlocks.Sample.Minimal.WebAPI; app.UseAuthorization(); app.MapDependencyHealthChecks(); app.MapControllers(); -app.MapStartPage(applicationInfo.Name); +// app.MapStartPage(applicationInfo.Name); app.Run(); @@ -98,3 +180,7 @@ public static void Main(string[] args) // }, args); } } + +public class MappingProfile : Profile +{ +} diff --git a/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Routers/ValuesRouter.cs b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Routers/ValuesRouter.cs new file mode 100644 index 0000000..d816350 --- /dev/null +++ b/src/Samples/LittleBlocks.Sample.MinimalApi..WebAPI/Routers/ValuesRouter.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LittleBlocks.Sample.Minimal.WebAPI.Routers; + +public static class ValuesRouter +{ + public static WebApplication MapValuesRouter(this WebApplication app) + { + var mapGroup = app.MapGroup("values"); + + mapGroup.MapGet("", async Task ( + HttpContext context, + CancellationToken cancellationToken) => TypedResults.Ok(new[] { "value1", "value2" })) + .WithName("GetAll") + .Produces>() + .WithOpenApi() + .WithTags("values"); + + mapGroup.MapGet("{id:int}", async Task ( + int id, + HttpContext context, + CancellationToken cancellationToken) => TypedResults.Ok("value1")) + .WithName("GetValueById") + .Produces() + .WithOpenApi() + .WithTags("values"); + + mapGroup.MapPost("", async Task ( + [FromBody] string value, + HttpContext context, + CancellationToken cancellationToken) =>TypedResults.Ok(value)) + .WithName("Post") + .Produces() + .WithOpenApi() + .WithTags("values"); + + mapGroup.MapPut("{id}", async Task ( + int id, + [FromBody] string value, + HttpContext context, + CancellationToken cancellationToken) => TypedResults.Ok(value)) + .WithName("Put") + .Produces() + .WithOpenApi() + .WithTags("values"); + + mapGroup.MapDelete("{id}", async Task ( + int id, + HttpContext context, + CancellationToken cancellationToken) =>TypedResults.Ok(true)) + .WithName("Delete") + .Produces() + .WithOpenApi() + .WithTags("values"); + + return app; + } +} \ No newline at end of file