Update exception handling (#146)

This commit is contained in:
Aaron Po
2026-02-12 21:06:07 -05:00
committed by GitHub
parent 584fe6282f
commit 7129e5679e
28 changed files with 191 additions and 126 deletions

View File

@@ -18,7 +18,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />

View File

@@ -44,13 +44,6 @@ namespace API.Core.Controllers
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var userAccount = await login.LoginAsync(req.Username, req.Password);
if (userAccount is null)
{
return Unauthorized(new ResponseBody
{
Message = "Invalid username or password."
});
}
UserDTO dto = new(userAccount.UserAccountId, userAccount.Username);
@@ -64,4 +57,4 @@ namespace API.Core.Controllers
});
}
}
}
}

View File

@@ -19,7 +19,6 @@ namespace API.Core.Controllers
public async Task<ActionResult<UserAccount>> GetById(Guid id)
{
var user = await userService.GetByIdAsync(id);
if (user is null) return NotFound();
return Ok(user);
}
}

View File

@@ -9,7 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -0,0 +1,84 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
var errors = fluentValidationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult(new
{
message = "Validation failed",
errors
});
context.ExceptionHandled = true;
break;
case ConflictException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 409
};
context.ExceptionHandled = true;
break;
case NotFoundException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 404
};
context.ExceptionHandled = true;
break;
case UnauthorizedException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 401
};
context.ExceptionHandled = true;
break;
case ForbiddenException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 403
};
context.ExceptionHandled = true;
break;
case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 400
};
context.ExceptionHandled = true;
break;
default:
context.Result = new ObjectResult(new ResponseBody { Message = "An unexpected error occurred" })
{
StatusCode = 500
};
context.ExceptionHandled = true;
break;
}
}
}

View File

@@ -1,47 +0,0 @@
using System.Net;
using System.Text.Json;
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Middleware;
public class ValidationExceptionHandlingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
await HandleValidationExceptionAsync(context, ex);
}
}
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
var errors = exception.Errors
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: "Validation failed. " + string.Join(" ", errors);
var response = new ResponseBody
{
Message = message
};
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions));
}
}

View File

@@ -1,3 +1,5 @@
using API.Core;
using Domain.Exceptions;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
@@ -6,34 +8,19 @@ using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Service.Auth.Auth;
using Service.UserManagement.User;
using API.Core.Contracts.Common;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
// Global Exception Filter
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
var message = errors.Count == 1
? errors[0]
: string.Join(" ", errors);
var response = new
{
message
};
return new BadRequestObjectResult(response);
};
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
@@ -67,6 +54,8 @@ builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
var app = builder.Build();
@@ -90,6 +79,3 @@ lifetime.ApplicationStopping.Register(() =>
});
app.Run();
// Make Program class accessible to test projects
public partial class Program { }

View File

@@ -3,7 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -12,25 +12,19 @@ Feature: User Registration
And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token
@Ignore
Scenario: Registration fails with existing username
Given the API is running
And I have an existing account with username "existinguser"
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| existinguser | Existing | User | existing@example.com | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409
And the response JSON should have "message" equal "Username already exists."
@Ignore
Scenario: Registration fails with existing email
Given the API is running
And I have an existing account with email "existing@example.com"
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | existing@example.com | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
Then the response has HTTP status 409
And the response JSON should have "message" equal "Email already in use."
Scenario: Registration fails with missing required fields
Given the API is running

View File

@@ -201,18 +201,6 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseBodyKey] = responseBody;
}
[Given("I have an existing account with username {string}")]
public void GivenIHaveAnExistingAccountWithUsername(string username)
{
}
[Given("I have an existing account with email {string}")]
public void GivenIHaveAnExistingAccountWithEmail(string email)
{
}
[When("I submit a registration request using a GET request")]
public async Task WhenISubmitARegistrationRequestUsingAGetRequest()
{