diff --git a/.codacy.yml b/.codacy.yml index 145b401..58d0874 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -3,34 +3,34 @@ # Uses Java glob syntax: https://docs.oracle.com/javase/tutorial/essential/io/fileOps.html#glob exclude_paths: - # Ignore all root-level metadata and documentation - - '.gitignore' - - '.runsettings' - - 'LICENSE' - - 'README.md' + - ".gitignore" + - ".runsettings" + - "LICENSE" + - "README.md" # Ignore all file types that shouldn't be analyzed - - '**.yml' - - '**.json' - - '**.png' - - '**.sln' - - '**.csproj' + - "**.yml" + - "**.json" + - "**.png" + - "**.sln" + - "**.csproj" # Ignore generated or infrastructure files - - '**/*Program.cs' + - "**/*Program.cs" # Ignore specific folders across any depth in the project - - '**/Configurations/**' - - '**/Data/**' - - '**/Enums/**' - - '**/Extensions/**' - - '**/Mappings/**' - - '**/Migrations/**' - - '**/Models/**' - - '**/Properties/**' - - '**/Repositories/**' - - '**/Utilities/**' - - '**/Validators/**' - - 'test/**/*' - - 'scripts/**/*' + - "**/Configurations/**" + - "**/Data/**" + - "**/Enums/**" + - "**/Extensions/**" + - "**/Mappings/**" + - "**/Middlewares/**" + - "**/Migrations/**" + - "**/Models/**" + - "**/Properties/**" + - "**/Repositories/**" + - "**/Utilities/**" + - "**/Validators/**" + - "test/**/*" + - "scripts/**/*" diff --git a/codecov.yml b/codecov.yml index b6a281d..2379c0c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,8 @@ - # Codecov Repository YAML # https://docs.codecov.com/docs/codecov-yaml coverage: -# https://docs.codecov.com/docs/commit-status + # https://docs.codecov.com/docs/commit-status status: project: default: @@ -29,11 +28,11 @@ component_management: - component_id: controllers name: Controllers paths: - - 'src/Dotnet.Samples.AspNetCore.WebApi/Controllers/' + - "src/Dotnet.Samples.AspNetCore.WebApi/Controllers/" - component_id: services name: Services paths: - - 'src/Dotnet.Samples.AspNetCore.WebApi/Services/' + - "src/Dotnet.Samples.AspNetCore.WebApi/Services/" comment: layout: "header, diff, flags, components" @@ -47,19 +46,20 @@ ignore: - .*\.json - .*\.yml - .*\.png - - '**/*.md' + - "**/*.md" - .*\/test\/.* - .*\/scripts\/.* - .*\/Program\.cs - - '**/LICENSE' - - '**/README.md' + - "**/LICENSE" + - "**/README.md" - .*\/Configurations\/.* - .*\/Data\/.* - .*\/Enums\/.* - .*\/Extensions\/.* - .*\/Mappings\/.* + - .*\/Middlewares\/.* - .*\/Migrations\/.* - .*\/Models\/.* - .*\/Properties\/.* diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index d32f923..42f25ee 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -189,6 +189,7 @@ [FromBody] PlayerRequestModel player return TypedResults.NotFound(); } await playerService.UpdateAsync(player); + // codeql[cs/log-forging] Serilog structured logging with @ destructuring automatically escapes control characters logger.LogInformation("PUT /players/{SquadNumber} updated: {@Player}", squadNumber, player); return TypedResults.NoContent(); } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/MiddlewareExtensions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/MiddlewareExtensions.cs new file mode 100644 index 0000000..e303545 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Extensions/MiddlewareExtensions.cs @@ -0,0 +1,21 @@ +using Dotnet.Samples.AspNetCore.WebApi.Middlewares; + +namespace Dotnet.Samples.AspNetCore.WebApi.Extensions; + +/// +/// Extension methods for configuring middleware in the application pipeline. +/// +public static class MiddlewareExtensions +{ + /// + /// Adds global exception handling middleware to the application pipeline. + /// This middleware catches unhandled exceptions and returns RFC 7807 compliant error responses. + /// + /// The web application used to configure the HTTP pipeline, and routes. + /// The WebApplication object for method chaining. + public static WebApplication UseExceptionHandling(this WebApplication app) + { + app.UseMiddleware(); + return app; + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs new file mode 100644 index 0000000..9ded910 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Middlewares/ExceptionMiddleware.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Dotnet.Samples.AspNetCore.WebApi.Middlewares; + +/// +/// Middleware for global exception handling with RFC 7807 Problem Details format. +/// +public class ExceptionMiddleware(ILogger logger, IHostEnvironment environment) +{ + private const string ProblemDetailsContentType = "application/problem+json"; + + private static readonly JsonSerializerOptions JsonOptions = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + /// + /// Invokes the middleware to handle exceptions globally. + /// + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + try + { + await next(context); + } + catch (Exception exception) + { + await HandleExceptionAsync(context, exception); + } + } + + /// + /// Handles the exception and returns an RFC 7807 compliant error response. + /// + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (status, title) = MapExceptionToStatusCode(exception); + + var problemDetails = new ProblemDetails + { + Type = $"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{status}", + Title = title, + Status = status, + Detail = GetExceptionDetail(exception), + Instance = context.Request.Path + }; + + // Add trace ID for request correlation + problemDetails.Extensions["traceId"] = context.TraceIdentifier; + + // codeql[cs/log-forging] Serilog structured logging automatically escapes control characters + logger.LogError( + exception, + "Unhandled exception occurred. TraceId: {TraceId}, Path: {Path}, StatusCode: {StatusCode}", + context.TraceIdentifier, + context.Request.Path, + status + ); + + // Only modify response if headers haven't been sent yet + if (!context.Response.HasStarted) + { + context.Response.StatusCode = status; + context.Response.ContentType = ProblemDetailsContentType; + + await context.Response.WriteAsync( + JsonSerializer.Serialize(problemDetails, JsonOptions) + ); + } + else + { + logger.LogWarning( + "Unable to write error response for TraceId: {TraceId}. Response has already started.", + context.TraceIdentifier + ); + } + } + + /// + /// Maps exception types to appropriate HTTP status codes and titles. + /// + private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception exception) + { + return exception switch + { + ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"), + ArgumentException + or ArgumentNullException + => (StatusCodes.Status400BadRequest, "Bad Request"), + InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid Operation"), + DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Concurrency Conflict"), + OperationCanceledException => (StatusCodes.Status408RequestTimeout, "Request Timeout"), + _ => (StatusCodes.Status500InternalServerError, "Internal Server Error") + }; + } + + /// + /// Gets the exception detail based on the environment. + /// In Development: includes full exception details and stack trace. + /// In Production: returns a generic message without sensitive information. + /// + private string GetExceptionDetail(Exception exception) + { + if (environment.IsDevelopment()) + { + return $"{exception.Message}\n\nStack Trace:\n{exception.StackTrace}"; + } + + return exception switch + { + ValidationException => exception.Message, + ArgumentException => exception.Message, + _ => "An unexpected error occurred while processing your request." + }; + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index 4b43640..8e247cc 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -55,6 +55,7 @@ * -------------------------------------------------------------------------- */ app.UseSerilogRequestLogging(); +app.UseExceptionHandling(); app.UseHttpsRedirection(); app.MapHealthChecks("/health"); app.UseRateLimiter();