feat(backend/infra): add health checks, CORS, Serilog logging, and exception middleware

- Add Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package
- Add Serilog.AspNetCore and Serilog.Sinks.Console packages
- Configure Serilog with bootstrap logger and configuration
- Add Health Checks endpoint at /health with DbContext check
- Configure CORS with AllowAll policy for development
- Create ApiResponse, ApiErrorResponse, and PaginatedResponse models
- Create ExceptionMiddleware for global error handling
- Restructure Program.cs with try-catch-finally for proper logging

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-05-31 19:52:00 +02:00
parent accb06bc7c
commit 4ee2b2568a
4 changed files with 231 additions and 69 deletions

View file

@ -19,6 +19,10 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -5,88 +5,137 @@ using GermanApp.Infrastructure.Data.DbContext;
using GermanApp.Infrastructure.Data.Repositories; using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Infrastructure.Data.SeedData; using GermanApp.Infrastructure.Data.SeedData;
using GermanApp.Presentation.Endpoints; using GermanApp.Presentation.Endpoints;
using GermanApp.Shared.Middleware;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args); // Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateBootstrapLogger();
// Add services to the container. try
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{ {
// Using PostgreSQL for production and development var builder = WebApplication.CreateBuilder(args);
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres"); // Add Serilog to the builder
builder.Host.UseSerilog((ctx, lc) => lc
// Enable sensitive data logging in development .MinimumLevel.Debug()
if (builder.Environment.IsDevelopment()) .WriteTo.Console()
.ReadFrom.Configuration(ctx.Configuration));
// ============================================
// PRESENTATION LAYER - API Configuration
// ============================================
// Add services to the container.
builder.Services.AddOpenApi();
// Add Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
// Configure CORS
builder.Services.AddCors(options =>
{ {
options.EnableSensitiveDataLogging(); options.AddPolicy("AllowAll", builder =>
options.EnableDetailedErrors(); {
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{
// Using PostgreSQL for production and development
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Port=5432;Database=DeutschLernen;Username=postgres;Password=postgres");
// Enable sensitive data logging in development
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
var app = builder.Build();
// Configure the HTTP request pipeline.
// Use exception middleware first (to catch all exceptions)
app.UseExceptionMiddleware();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
} }
});
// Register repositories (Infrastructure implementations of Domain interfaces) // Use CORS
builder.Services.AddScoped<ILessonRepository, LessonRepository>(); app.UseCors("AllowAll");
// ============================================ // Use Health Checks
// APPLICATION LAYER - Use Cases & Services app.MapHealthChecks("/health");
// ============================================
// Register command handlers // Seed database with initial data
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>(); app.SeedDatabase();
// ============================================ // Map Clean Architecture endpoints
// PRESENTATION LAYER - API app.MapLessonsEndpoints();
// ============================================
// Add services for Minimal APIs // Keep original WeatherForecast endpoint for reference
builder.Services.AddEndpointsApiExplorer(); app.MapGet("/weatherforecast", () =>
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
// Seed database with initial data
app.SeedDatabase();
// Map Clean Architecture endpoints
app.MapLessonsEndpoints();
// Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () =>
{
var summaries = new[]
{ {
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" var summaries = new[]
}; {
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
var forecast = Enumerable.Range(1, 5).Select(index => };
new WeatherForecast
( var forecast = Enumerable.Range(1, 5).Select(index =>
DateOnly.FromDateTime(DateTime.Now.AddDays(index)), new WeatherForecast
Random.Shared.Next(-20, 55), (
summaries[Random.Shared.Next(summaries.Length)] DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
)) Random.Shared.Next(-20, 55),
.ToArray(); summaries[Random.Shared.Next(summaries.Length)]
return forecast; ))
}) .ToArray();
.WithName("GetWeatherForecast"); return forecast;
})
.WithName("GetWeatherForecast");
app.Run(); app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
// Existing record for reference // Existing record for reference
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)

View file

@ -0,0 +1,62 @@
using System.Net;
using System.Text.Json;
using GermanApp.Shared.Models;
namespace GermanApp.Shared.Middleware;
/// <summary>
/// Global exception handling middleware.
/// Catches exceptions and returns consistent error responses.
/// </summary>
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred: {Message}", ex.Message);
await HandleExceptionAsync(httpContext, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new ApiErrorResponse(
"An unexpected error occurred. Please try again later.",
context.Response.StatusCode,
exception.Message
);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
}
}
/// <summary>
/// Extension method to add exception middleware.
/// </summary>
public static class ExceptionMiddlewareExtensions
{
public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder builder) =>
builder.UseMiddleware<ExceptionMiddleware>();
}

View file

@ -0,0 +1,47 @@
namespace GermanApp.Shared.Models;
/// <summary>
/// Base API response wrapper for consistent API responses.
/// </summary>
/// <typeparam name="T">The data type of the response</typeparam>
public record ApiResponse<T>(T Data, string Message = "Success", bool Success = true)
{
/// <summary>
/// Creates a successful response.
/// </summary>
public static ApiResponse<T> Ok(T data, string message = "Success") =>
new ApiResponse<T>(data, message, true);
}
/// <summary>
/// API error response for consistent error handling.
/// </summary>
public record ApiErrorResponse(string Message, int StatusCode, string? Details = null)
{
public bool Success => false;
/// <summary>
/// Creates an error response.
/// </summary>
public static ApiErrorResponse Error(string message, int statusCode, string? details = null) =>
new ApiErrorResponse(message, statusCode, details);
}
/// <summary>
/// Paginated API response.
/// </summary>
/// <typeparam name="T">The data type of the items</typeparam>
public record PaginatedResponse<T>(IEnumerable<T> Items, int PageNumber, int PageSize, int TotalCount, int TotalPages)
{
/// <summary>
/// Creates a paginated response.
/// </summary>
public static PaginatedResponse<T> Create(IEnumerable<T> items, int pageNumber, int pageSize, int totalCount) =>
new PaginatedResponse<T>(
items,
pageNumber,
pageSize,
totalCount,
(int)Math.Ceiling(totalCount / (double)pageSize)
);
}