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()
{

View File

@@ -8,7 +8,8 @@
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
</Folder>
<Folder Name="/Domain/">
<Project Path="Domain/Domain.csproj" />
<Project Path="Domain.Entities\Domain.Entities.csproj" />
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />

View File

@@ -18,7 +18,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -124,6 +124,7 @@ internal class UserSeeder : ISeeder
int createdCredentials = 0;
int createdVerifications = 0;
// create a known user for testing purposes
{
const string firstName = "Test";
const string lastName = "User";
@@ -264,4 +265,4 @@ internal class UserSeeder : ISeeder
int offsetDays = random.Next(0, 365);
return baseDate.AddDays(-offsetDays);
}
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,33 @@
namespace Domain.Exceptions;
/// <summary>
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
/// Maps to HTTP 409 Conflict.
/// </summary>
public class ConflictException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a requested resource is not found.
/// Maps to HTTP 404 Not Found.
/// </summary>
public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs
/// <summary>
/// Exception thrown when authentication fails or is required.
/// Maps to HTTP 401 Unauthorized.
/// </summary>
public class UnauthorizedException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
/// Maps to HTTP 403 Forbidden.
/// </summary>
public class ForbiddenException(string message) : Exception(message);
/// <summary>
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
/// Maps to HTTP 400 Bad Request.
/// </summary>
public class ValidationException(string message) : Exception(message);

View File

@@ -1,7 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
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.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"

View File

@@ -18,6 +18,6 @@
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,5 +5,5 @@ namespace Service.Auth.Auth;
public interface ILoginService
{
Task<UserAccount?> LoginAsync(string username, string password);
Task<UserAccount> LoginAsync(string username, string password);
}

View File

@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
@@ -11,18 +12,24 @@ public class LoginService(
) : ILoginService
{
public async Task<UserAccount?> LoginAsync(string username, string password)
public async Task<UserAccount> LoginAsync(string username, string password)
{
// Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found
if (user is null) return null;
if (user is null)
throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null;
return !passwordInfrastructure.Verify(password, activeCred.Hash) ? null : user;
if (activeCred is null)
throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
return user;
}
}

View File

@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
@@ -13,12 +14,16 @@ public class RegisterService(
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
{
// Check if user already exists
var user = await authRepo.GetUserByUsernameAsync(userAccount.Username);
if (user is not null)
var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
if (existingUsername != null || existingEmail != null)
{
return null!;
throw new ConflictException("Username or email already exists");
}
// password hashing
var hashed = passwordInfrastructure.Hash(password);
@@ -32,5 +37,5 @@ public class RegisterService(
hashed);
}
}

View File

@@ -10,8 +10,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.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.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup>

View File

@@ -7,7 +7,9 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@ namespace Service.UserManagement.User;
public interface IUserService
{
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit = null, int? offset = null);
Task<UserAccount?> GetByIdAsync(Guid id);
Task<UserAccount> GetByIdAsync(Guid id);
Task UpdateAsync(UserAccount userAccount);
}

View File

@@ -1,4 +1,5 @@
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Repository.UserAccount;
namespace Service.UserManagement.User;
@@ -10,9 +11,12 @@ public class UserService(IUserAccountRepository repository) : IUserService
return await repository.GetAllAsync(limit, offset);
}
public async Task<UserAccount?> GetByIdAsync(Guid id)
public async Task<UserAccount> GetByIdAsync(Guid id)
{
return await repository.GetByIdAsync(id);
var user = await repository.GetByIdAsync(id);
if (user is null)
throw new NotFoundException($"User with ID {id} not found");
return user;
}
public async Task UpdateAsync(UserAccount userAccount)