Previous Article: ASP.NET Core Swagger Mastery: Interactive API Documentation Guide (Part-15 of 40)
![Module 16 of 40 : ASP.NET Core Middleware Mastery: Custom Logging, Auth & Pipeline Control Module 16 of 40 : ASP.NET Core Middleware Mastery: Custom Logging, Auth & Pipeline Control]()
Table of Contents
Introduction to Middleware
Understanding the Request Pipeline
Built-in Middleware Components
Creating Custom Middleware
Real-World Middleware Examples
Middleware Ordering and Execution
Advanced Middleware Patterns
Error Handling Middleware
Performance Optimization
Testing Middleware
Best Practices and Alternatives
1. Introduction to Middleware
What is Middleware?
Middleware in ASP.NET Core is software components that are assembled into an application pipeline to handle requests and responses. Each component:
// Simple middleware concept
app.Use(async (context, next) =>
{
// Do work before the next middleware
await next.Invoke();
// Do work after the next middleware
});
Real-Life Analogy: Airport Security Check
Think of middleware as airport security checks:
Ticket Verification - Check if you have a valid ticket
Security Screening - Scan luggage and personal items
Passport Control - Verify travel documents
Boarding Gate - Final check before boarding
Each step can either pass you to the next or reject you entirely.
2. Understanding the Request Pipeline
Basic Pipeline Structure
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
Pipeline Flow Visualization
Request → Middleware 1 → Middleware 2 → ... → Middleware N → Endpoint → Response
Lifecycle Example: E-Commerce Request
// Program.cs - Complete pipeline setup
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddControllers();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure pipeline
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRequestLogging(); // Custom middleware
app.UsePerformanceMonitoring(); // Custom middleware
app.MapControllers();
app.Run();
3. Built-in Middleware Components
Essential Built-in Middleware
3.1 Static Files Middleware
// Serve static files like HTML, CSS, JavaScript, images
app.UseStaticFiles();
// Serve files from custom directory
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(Directory.GetCurrentDirectory(), "MyStaticFiles")),
RequestPath = "/StaticFiles"
});
3.2 Routing Middleware
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapGet("/hello", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
});
3.3 Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
3.4 CORS Middleware
// Add CORS service
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Use CORS middleware
app.UseCors("AllowAll");
4. Creating Custom Middleware
4.1 Inline Middleware
app.Use(async (context, next) =>
{
var startTime = DateTime.UtcNow;
// Call the next middleware
await next();
var endTime = DateTime.UtcNow;
var duration = endTime - startTime;
Console.WriteLine($"Request took: {duration.TotalMilliseconds}ms");
});
4.2 Class-Based Middleware
Basic Custom Middleware Class
// Custom logging middleware
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var startTime = DateTime.UtcNow;
_logger.LogInformation($"Starting request: {context.Request.Method} {context.Request.Path}");
try
{
await _next(context);
}
finally
{
var endTime = DateTime.UtcNow;
var duration = endTime - startTime;
_logger.LogInformation(
$"Completed request: {context.Request.Method} {context.Request.Path} " +
$"with status: {context.Response.StatusCode} in {duration.TotalMilliseconds}ms");
}
}
}
// Extension method for easy use
public static class RequestLoggingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestLoggingMiddleware>();
}
}
4.3 Factory-Based Middleware
// Factory-activated middleware
public class TimingMiddleware : IMiddleware
{
private readonly ILogger<TimingMiddleware> _logger;
public TimingMiddleware(ILogger<TimingMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation($"Starting request: {context.Request.Path}");
await next(context);
stopwatch.Stop();
_logger.LogInformation($"Request {context.Request.Path} completed in {stopwatch.ElapsedMilliseconds}ms");
}
}
// Register in DI container
builder.Services.AddTransient<TimingMiddleware>();
5. Real-World Middleware Examples
5.1 API Key Authentication Middleware
// API Key Authentication Middleware
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
private const string API_KEY_HEADER = "X-API-Key";
public ApiKeyMiddleware(RequestDelegate next, IConfiguration configuration)
{
_next = next;
_configuration = configuration;
}
public async Task InvokeAsync(HttpContext context)
{
// Skip API key check for certain paths
if (context.Request.Path.StartsWithSegments("/public"))
{
await _next(context);
return;
}
if (!context.Request.Headers.TryGetValue(API_KEY_HEADER, out var extractedApiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API Key is missing");
return;
}
var validApiKeys = _configuration.GetSection("ValidApiKeys").Get<List<string>>();
if (!validApiKeys.Contains(extractedApiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Invalid API Key");
return;
}
await _next(context);
}
}
// Extension method
public static class ApiKeyMiddlewareExtensions
{
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ApiKeyMiddleware>();
}
}
5.2 Request/Response Logging Middleware
// Detailed request/response logging middleware
public class DetailedLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<DetailedLoggingMiddleware> _logger;
public DetailedLoggingMiddleware(RequestDelegate next, ILogger<DetailedLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Log request
await LogRequest(context);
// Copy original response body stream
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
throw;
}
finally
{
// Log response
await LogResponse(context, responseBody, originalBodyStream);
}
}
private async Task LogRequest(HttpContext context)
{
context.Request.EnableBuffering();
var request = $"{context.Request.Method} {context.Request.Path}{context.Request.QueryString}";
var headers = string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}"));
_logger.LogInformation($"Request: {request}");
_logger.LogDebug($"Headers: {headers}");
if (context.Request.ContentLength > 0)
{
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
_logger.LogDebug($"Request Body: {body}");
}
}
private async Task LogResponse(HttpContext context, MemoryStream responseBody, Stream originalBodyStream)
{
responseBody.Position = 0;
var responseText = await new StreamReader(responseBody).ReadToEndAsync();
responseBody.Position = 0;
_logger.LogInformation($"Response: {context.Response.StatusCode}");
_logger.LogDebug($"Response Body: {responseText}");
await responseBody.CopyToAsync(originalBodyStream);
context.Response.Body = originalBodyStream;
}
}
5.3 Rate Limiting Middleware
// Simple rate limiting middleware
public class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly Dictionary<string, List<DateTime>> _requestLog = new();
private readonly object _lockObject = new object();
private readonly int _maxRequests = 100;
private readonly TimeSpan _timeWindow = TimeSpan.FromMinutes(1);
public RateLimitingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (IsRateLimited(clientIp))
{
context.Response.StatusCode = 429; // Too Many Requests
await context.Response.WriteAsync("Rate limit exceeded. Please try again later.");
return;
}
await _next(context);
}
private bool IsRateLimited(string clientIp)
{
lock (_lockObject)
{
var now = DateTime.UtcNow;
if (!_requestLog.ContainsKey(clientIp))
{
_requestLog[clientIp] = new List<DateTime>();
}
// Remove old requests outside the time window
_requestLog[clientIp].RemoveAll(time => now - time > _timeWindow);
// Check if over limit
if (_requestLog[clientIp].Count >= _maxRequests)
{
return true;
}
// Log this request
_requestLog[clientIp].Add(now);
return false;
}
}
}
5.4 Correlation ID Middleware
// Correlation ID middleware for request tracking
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CORRELATION_ID_HEADER = "X-Correlation-ID";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ILogger<CorrelationIdMiddleware> logger)
{
var correlationId = GetOrCreateCorrelationId(context);
// Add correlation ID to response headers
context.Response.OnStarting(() =>
{
context.Response.Headers[CORRELATION_ID_HEADER] = correlationId;
return Task.CompletedTask;
});
// Add to logger scope
using (logger.BeginScope("{CorrelationId}", correlationId))
{
await _next(context);
}
}
private string GetOrCreateCorrelationId(HttpContext context)
{
if (context.Request.Headers.TryGetValue(CORRELATION_ID_HEADER, out var correlationId))
{
return correlationId!;
}
return Guid.NewGuid().ToString();
}
}
6. Middleware Ordering and Execution
Critical Middleware Order
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// 1. Exception/Error Handling
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
// 2. HTTPS Redirection
app.UseHttpsRedirection();
// 3. Static Files
app.UseStaticFiles();
// 4. Routing
app.UseRouting();
// 5. Custom Middleware (Authentication, Logging, etc.)
app.UseCorrelationId();
app.UseRequestLogging();
app.UseApiKeyAuthentication();
// 6. CORS
app.UseCors("AllowAll");
// 7. Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
// 8. Endpoints
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapRazorPages();
});
}
Ordering Impact Example
// Wrong order - Authentication won't work properly
app.UseEndpoints(endpoints => { /* ... */ });
app.UseAuthentication(); // This won't be called for endpoint requests
// Correct order
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { /* ... */ });
7. Advanced Middleware Patterns
7.1 Conditional Middleware
// Conditional middleware based on request path
public class ConditionalMiddleware
{
private readonly RequestDelegate _next;
private readonly string _pathStartsWith;
public ConditionalMiddleware(RequestDelegate next, string pathStartsWith)
{
_next = next;
_pathStartsWith = pathStartsWith;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments(_pathStartsWith))
{
// Custom logic for specific paths
context.Items["CustomFeature"] = "Enabled";
}
await _next(context);
}
}
// Usage with multiple conditions
app.MapWhen(context => context.Request.Path.StartsWithSegments("/api"),
apiApp =>
{
apiApp.UseMiddleware<ApiSpecificMiddleware>();
apiApp.UseRouting();
apiApp.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
});
7.2 Branching Middleware Pipeline
// Branching for different pipeline configurations
app.Map("/admin", adminApp =>
{
adminApp.UseMiddleware<AdminAuthenticationMiddleware>();
adminApp.UseRouting();
adminApp.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "admin",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
});
app.Map("/api", apiApp =>
{
apiApp.UseMiddleware<ApiKeyMiddleware>();
apiApp.UseRouting();
apiApp.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
});
7.3 Middleware with Configuration
// Configurable middleware
public class ConfigurableMiddleware
{
private readonly RequestDelegate _next;
private readonly ConfigurableMiddlewareOptions _options;
public ConfigurableMiddleware(RequestDelegate next, IOptions<ConfigurableMiddlewareOptions> options)
{
_next = next;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
if (_options.EnableFeature && context.Request.Path.StartsWithSegments(_options.ApplyToPath))
{
// Apply middleware logic
await ApplyCustomLogic(context);
}
await _next(context);
}
private async Task ApplyCustomLogic(HttpContext context)
{
// Custom implementation
context.Items["CustomFeatureApplied"] = true;
}
}
public class ConfigurableMiddlewareOptions
{
public bool EnableFeature { get; set; }
public string ApplyToPath { get; set; } = "/api";
}
// Registration
builder.Services.Configure<ConfigurableMiddlewareOptions>(options =>
{
options.EnableFeature = true;
options.ApplyToPath = "/secure";
});
8. Error Handling Middleware
Global Exception Handling Middleware
// Comprehensive error handling middleware
public class GlobalExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
private readonly IWebHostEnvironment _env;
public GlobalExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionHandlerMiddleware> logger,
IWebHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var errorResponse = new ErrorResponse
{
Success = false,
Error = "An unexpected error occurred"
};
switch (exception)
{
case UnauthorizedAccessException:
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
errorResponse.Error = "Unauthorized access";
break;
case KeyNotFoundException:
context.Response.StatusCode = StatusCodes.Status404NotFound;
errorResponse.Error = "Resource not found";
break;
case ValidationException vex:
context.Response.StatusCode = StatusCodes.Status400BadRequest;
errorResponse.Error = "Validation failed";
errorResponse.Details = vex.Errors;
break;
default:
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
if (_env.IsDevelopment())
{
errorResponse.Details = exception.ToString();
}
break;
}
var json = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await context.Response.WriteAsync(json);
}
}
public class ErrorResponse
{
public bool Success { get; set; }
public string Error { get; set; } = string.Empty;
public object? Details { get; set; }
}
Custom Exception Classes
// Custom exception for business logic
public class BusinessException : Exception
{
public string ErrorCode { get; }
public BusinessException(string errorCode, string message) : base(message)
{
ErrorCode = errorCode;
}
}
public class ValidationException : Exception
{
public Dictionary<string, string[]> Errors { get; }
public ValidationException(Dictionary<string, string[]> errors)
: base("Validation failed")
{
Errors = errors;
}
}
9. Performance Optimization
Response Caching Middleware
// Custom response caching middleware
public class ResponseCachingMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly ILogger<ResponseCachingMiddleware> _logger;
public ResponseCachingMiddleware(
RequestDelegate next,
IMemoryCache cache,
ILogger<ResponseCachingMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var cacheKey = GenerateCacheKey(context.Request);
if (context.Request.Method == "GET" && _cache.TryGetValue(cacheKey, out byte[]? cachedResponse))
{
_logger.LogInformation($"Serving cached response for {context.Request.Path}");
context.Response.ContentType = "application/json";
context.Response.ContentLength = cachedResponse?.Length ?? 0;
await context.Response.Body.WriteAsync(cachedResponse!);
return;
}
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await _next(context);
if (context.Response.StatusCode == 200)
{
var responseData = responseBody.ToArray();
// Cache the response
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5))
.SetSlidingExpiration(TimeSpan.FromMinutes(1));
_cache.Set(cacheKey, responseData, cacheOptions);
_logger.LogInformation($"Cached response for {context.Request.Path}");
}
await responseBody.CopyToAsync(originalBodyStream);
}
private string GenerateCacheKey(HttpRequest request)
{
return $"{request.Path}{request.QueryString}";
}
}
Performance Monitoring Middleware
// Performance monitoring middleware
public class PerformanceMonitoringMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PerformanceMonitoringMiddleware> _logger;
public PerformanceMonitoringMiddleware(
RequestDelegate next,
ILogger<PerformanceMonitoringMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var startMemory = GC.GetTotalMemory(false);
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
var endMemory = GC.GetTotalMemory(false);
var memoryUsed = endMemory - startMemory;
_logger.LogInformation(
$"Performance - Path: {context.Request.Path}, " +
$"Duration: {stopwatch.ElapsedMilliseconds}ms, " +
$"Memory: {memoryUsed} bytes, " +
$"Status: {context.Response.StatusCode}");
// Log warning for slow requests
if (stopwatch.ElapsedMilliseconds > 1000)
{
_logger.LogWarning(
$"Slow request detected: {context.Request.Path} " +
$"took {stopwatch.ElapsedMilliseconds}ms");
}
}
}
}
10. Testing Middleware
Unit Testing Middleware
// Unit tests for custom middleware
public class RequestLoggingMiddlewareTests
{
[Fact]
public async Task InvokeAsync_LogsRequestInformation()
{
// Arrange
var loggerMock = new Mock<ILogger<RequestLoggingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var middleware = new RequestLoggingMiddleware(nextMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Path = "/api/test";
// Act
await middleware.InvokeAsync(context);
// Assert
nextMock.Verify(next => next(context), Times.Once);
// Verify logging calls
}
[Fact]
public async Task InvokeAsync_WhenException_LogsError()
{
// Arrange
var loggerMock = new Mock<ILogger<RequestLoggingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
nextMock.Setup(next => next(It.IsAny<HttpContext>()))
.ThrowsAsync(new Exception("Test exception"));
var middleware = new RequestLoggingMiddleware(nextMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => middleware.InvokeAsync(context));
// Verify error was logged
}
}
// Integration testing
public class MiddlewareIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public MiddlewareIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CustomMiddleware_AddsCorrelationHeader()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/test");
// Assert
response.Headers.Contains("X-Correlation-ID").Should().BeTrue();
}
}
11. Best Practices and Alternatives
Middleware Best Practices
11.1 Do's and Don'ts
Do:
Keep middleware focused and single-purpose
Use dependency injection properly
Handle exceptions appropriately
Consider performance implications
Use appropriate logging levels
Don't:
Put business logic in middleware
Block the pipeline unnecessarily
Ignore async/await patterns
Forget to call next() when needed
11.2 Performance Considerations
// Good - Async all the way
public async Task InvokeAsync(HttpContext context)
{
await SomeAsyncOperation();
await _next(context);
}
// Bad - Mixing sync and async
public async Task InvokeAsync(HttpContext context)
{
SomeSyncOperation(); // Blocks thread
await _next(context);
}
Alternatives to Custom Middleware
11.3 Action Filters
// Use action filters for controller-specific logic
public class LogActionFilter : IActionFilter
{
private readonly ILogger<LogActionFilter> _logger;
public LogActionFilter(ILogger<LogActionFilter> logger)
{
_logger = logger;
}
public void OnActionExecuting(ActionExecutingContext context)
{
_logger.LogInformation($"Executing action: {context.ActionDescriptor.DisplayName}");
}
public void OnActionExecuted(ActionExecutedContext context)
{
_logger.LogInformation($"Executed action: {context.ActionDescriptor.DisplayName}");
}
}
11.4 Endpoint Filters
// Endpoint filters for minimal APIs
app.MapGet("/api/products", () =>
{
return Results.Ok(products);
})
.AddEndpointFilter<LoggingEndpointFilter>();
public class LoggingEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
Console.WriteLine($"Before: {context.HttpContext.Request.Path}");
var result = await next(context);
Console.WriteLine($"After: {context.HttpContext.Request.Path}");
return result;
}
}
Complete Real-World Example: E-Commerce Pipeline
// Complete e-commerce application pipeline
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Exception handling
app.UseGlobalExceptionHandler();
// Security
app.UseHsts();
app.UseHttpsRedirection();
// Performance
app.UseResponseCompression();
app.UseResponseCaching();
// Static files
app.UseStaticFiles();
// Routing
app.UseRouting();
// Custom middleware
app.UseCorrelationId();
app.UseRequestLogging();
app.UsePerformanceMonitoring();
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
// Rate limiting for API
app.MapWhen(context => context.Request.Path.StartsWithSegments("/api"),
apiApp =>
{
apiApp.UseRateLimiting();
apiApp.UseApiKeyAuthentication();
});
// Endpoints
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapRazorPages();
// Health check
endpoints.MapHealthChecks("/health");
});
}