Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"
14 changes: 7 additions & 7 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"
Expand All @@ -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\/.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Dotnet.Samples.AspNetCore.WebApi.Middlewares;

namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;

/// <summary>
/// Extension methods for configuring middleware in the application pipeline.
/// </summary>
public static class MiddlewareExtensions
{
/// <summary>
/// Adds global exception handling middleware to the application pipeline.
/// This middleware catches unhandled exceptions and returns RFC 7807 compliant error responses.
/// </summary>
/// <param name="app">The web application used to configure the HTTP pipeline, and routes.</param>
/// <returns>The WebApplication object for method chaining.</returns>
public static WebApplication UseExceptionHandling(this WebApplication app)
{
app.UseMiddleware<ExceptionMiddleware>();
return app;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Text.Json;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Dotnet.Samples.AspNetCore.WebApi.Middlewares;

/// <summary>
/// Middleware for global exception handling with RFC 7807 Problem Details format.
/// </summary>
public class ExceptionMiddleware(ILogger<ExceptionMiddleware> logger, IHostEnvironment environment)
{
private const string ProblemDetailsContentType = "application/problem+json";

private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

/// <summary>
/// Invokes the middleware to handle exceptions globally.
/// </summary>
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception exception)
{
await HandleExceptionAsync(context, exception);
}
}

/// <summary>
/// Handles the exception and returns an RFC 7807 compliant error response.
/// </summary>
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
);
}
}

/// <summary>
/// Maps exception types to appropriate HTTP status codes and titles.
/// </summary>
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")
};
}

/// <summary>
/// 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.
/// </summary>
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."
};
}
}
1 change: 1 addition & 0 deletions src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
* -------------------------------------------------------------------------- */

app.UseSerilogRequestLogging();
app.UseExceptionHandling();
app.UseHttpsRedirection();
app.MapHealthChecks("/health");
app.UseRateLimiter();
Expand Down