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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Riber - Changelog

## v4.0.1 - 19/10/2025
- **BREAKING CHANGE**: Reestruturação completa da resposta de erros da API
- Adicionada propriedade `type` para identificar o tipo do erro
- Alterada propriedade `messages` (array) para `message` (string única)
- Adicionada propriedade `details` (objeto) para erros de validação agrupados por campo
- Novo formato: `{ "type": "ERROR_TYPE", "message": "...", "details": { "Field": ["error1", "error2"] } }`
- **MELHORIA**: Erros de validação agora agrupam múltiplas mensagens por campo
- **MELHORIA**: Formato de erros mais alinhado com padrões modernos de API (inspirado em FastEndpoints)

---

## v3.0.1 - 18/10/2025
- CORREÇÃO: Troca do pacote de versionamento da API
- Ajuste no setup de testes da camada Api para testes de integração
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<Companies>Samuel Zedec</Companies>
<Copyright>Copyright © $([System.DateTime]::Now.Year)</Copyright>
<Description>Sistema de gestão financeira para um lanchonete local</Description>
<Version>3.0.1</Version>
<Version>4.0.0</Version>
</PropertyGroup>

<!-- Repositório e Documentação -->
Expand Down
27 changes: 13 additions & 14 deletions src/Riber.Api/Common/Api/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,19 @@ public async ValueTask<bool> TryHandleAsync(
{
logger.LogError(exception, "Exception occurred: {ExceptionType} - {Message}",
exception.GetType().Name, exception.Message);

(int statusCode, string[] messages) = exception switch
{
RequestTimeoutException timeoutEx => (timeoutEx.Code, [timeoutEx.Message]),
ValidationException validationEx => (ValidationException.Code, validationEx.Messages.Select(e => e.ErrorMessage).ToArray()),
Layer.ApplicationException applicationEx => (applicationEx.Code, [applicationEx.Message]),
DomainException domainEx => (StatusCodes.Status422UnprocessableEntity, [domainEx.Message]),
_ => (StatusCodes.Status500InternalServerError, ["Erro inesperado no servidor!"])
};

await httpContext.WriteUnauthorizedResponse(
code: statusCode,
messages: messages
);

(int statusCode, string message, Dictionary<string, string[]> details) = MapExceptionToError(exception);
await httpContext.WriteErrorResponse(statusCode, message, details);
return true;
}

private static (int StatusCode, string Message, Dictionary<string, string[]> Details) MapExceptionToError(Exception exception)
=> exception switch
{
RequestTimeoutException timeoutEx => (timeoutEx.Code, timeoutEx.Message, []),
ValidationException validationEx => (StatusCodes.Status400BadRequest, string.Empty, validationEx.Details),
Layer.ApplicationException applicationEx => (applicationEx.Code, applicationEx.Message, []),
DomainException domainEx => (StatusCodes.Status422UnprocessableEntity, domainEx.Message, []),
_ => (StatusCodes.Status500InternalServerError, "Erro inesperado no servidor.", [])
};
}
12 changes: 8 additions & 4 deletions src/Riber.Api/Extensions/HttpContextExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ public static class HttpContextExtension
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

public static async Task WriteUnauthorizedResponse(
public static async Task WriteErrorResponse(
this HttpContext context,
int code,
params string[] messages)
string message,
Dictionary<string, string[]> details)
{
var jsonResponse = JsonSerializer.Serialize(
Result.Failure<object>(new Error(GetErrorCode(code), messages)), JsonOptions);
var errorType = GetErrorCode(code);
var response = details.Count == 0
? Result.Failure<object>(new Error(errorType, message))
: Result.Failure<object>(new Error(errorType, details));

var jsonResponse = JsonSerializer.Serialize(response, JsonOptions);
context.Response.StatusCode = code;
context.Response.ContentType = "application/json";

Expand Down
12 changes: 12 additions & 0 deletions src/Riber.Api/Extensions/IActionResultExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// using Microsoft.AspNetCore.Mvc;
// using Riber.Application.Common;
//
// namespace Riber.Api.Extensions;
//
// public static class IActionResultExtension
// {
// public static IActionResult MapStatusCode<T>(this IActionResult actionResult, Result<T> result)
// {
//
// }
// }
10 changes: 6 additions & 4 deletions src/Riber.Api/Middlewares/SecurityStampMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(tokenSecurityStamp))
{
logger.LogError("Valores: userId = {UserId}, securityStamp = {TokenSecurityStamp}", userId, tokenSecurityStamp);
await context.WriteUnauthorizedResponse(
await context.WriteErrorResponse(
StatusCodes.Status401Unauthorized,
"Token do usuário inválido ou mal formado."
"Token do usuário inválido ou mal formado.",
details: []
);
return;
}
Expand All @@ -34,9 +35,10 @@ await context.WriteUnauthorizedResponse(
var user = await authService.FindByIdAsync(userId);
if (user is null || user.SecurityStamp != tokenSecurityStamp)
{
await context.WriteUnauthorizedResponse(
await context.WriteErrorResponse(
code: StatusCodes.Status401Unauthorized,
messages: "Security stamp inválido ou usuário não encontrado."
message: "Security stamp inválido ou usuário não encontrado.",
details: []
);
return;
}
Expand Down
12 changes: 7 additions & 5 deletions src/Riber.Application/Behaviors/ValidationBehavior.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using FluentValidation;
using Mediator;
using Riber.Application.Common;
using Exceptions_ValidationException = Riber.Application.Exceptions.ValidationException;
using LayerValidationException = Riber.Application.Exceptions.ValidationException;

namespace Riber.Application.Behaviors;

Expand All @@ -22,11 +21,14 @@ public async ValueTask<TResponse> Handle(
.Select(x => x.Validate(context))
.Where(x => !x.IsValid)
.SelectMany(x => x.Errors)
.Select(x => new ValidationError(x.PropertyName, x.ErrorMessage))
.ToList();
.GroupBy(x => x.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.ErrorMessage).ToArray()
);

return validationErrors.Count > 0
? throw new Exceptions_ValidationException(validationErrors)
? throw new LayerValidationException(validationErrors)
: await next(message, cancellationToken);
}
}
14 changes: 11 additions & 3 deletions src/Riber.Application/Common/Result.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ protected Result(bool isSuccess, Error error)

#region Static Methods

public static Result<object> Success() => new(null, true, new Error());
public static Result<T> Success<T>(T value) => new(value, true, new Error());
public static Result<T> Failure<T>(Error error) => new(default, false, error);
public static Result<object> Success()
=> new(null, true, new Error());

public static Result<T> Success<T>(T value)
=> new(value, true, new Error());

public static Result<T> Failure<T>(Error error)
=> new(default, false, error);

public static Result<T> Failure<T>(string type, Dictionary<string, string[]> details)
=> new(default, false, new Error(type, details));

protected static Result<T> Create<T>(T? value) =>
value is not null ? Success(value) : Failure<T>(new Error());
Expand Down
3 changes: 0 additions & 3 deletions src/Riber.Application/Common/ValidationError.cs

This file was deleted.

5 changes: 2 additions & 3 deletions src/Riber.Application/Exceptions/ValidationException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

namespace Riber.Application.Exceptions;

public sealed class ValidationException(IEnumerable<ValidationError> messages) : Exception
public sealed class ValidationException(Dictionary<string, string[]> details) : Exception
{
public IEnumerable<ValidationError> Messages => messages;
public static int Code => (int)HttpStatusCode.BadRequest;
public Dictionary<string, string[]> Details => details;
}
24 changes: 16 additions & 8 deletions src/Riber.Domain/Abstractions/Error.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,29 @@ public sealed class Error
{
#region Properties

public string Code { get; init; } = string.Empty;
public string[] Messages { get; init; } = [];
public string Type { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public Dictionary<string, string[]> Details { get; set; } = [];

#endregion

#region Constructors

[JsonConstructor]
public Error() {}
public Error(string code, params string[] messages)
public Error() { }

public Error(string type, string message)
{
Code = code;
Messages = messages;
Type = type;
Message = message;
}


public Error(string type, Dictionary<string, string[]> details)
{
Type = type;
Message = "Dados Inválidos.";
Details = details;
}

#endregion
}
5 changes: 3 additions & 2 deletions tests/Riber.Api.Tests/Controllers/AuthControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ public async Task Should_NotLoginSuccessfully_WithInvalidCredentials()
loginResponse.Should().NotBeNull();
loginResponse.IsSuccess.Should().BeFalse();
loginResponse.Value.Should().BeNull();
loginResponse.Error.Code.Should().Be("NOT_FOUND");
loginResponse.Error.Messages.Should().HaveCountGreaterThan(0);
loginResponse.Error.Type.Should().Be("NOT_FOUND");
loginResponse.Error.Message.Should().NotBeNullOrEmpty();
loginResponse.Error.Details.Should().BeNullOrEmpty();
}

#endregion
Expand Down
82 changes: 45 additions & 37 deletions tests/Riber.Application.Tests/Behaviors/ValidationBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public async Task Handle_WhenNoValidatorsProvided_ShouldProceedToNextHandler()
);

// Assert
result.Should().Be(result);
result.Should().Be(_response);
}

[Trait("Category", "Unit")]
Expand All @@ -65,7 +65,7 @@ public async Task Handle_WhenValidationPasses_ShouldProceedToNextHandler()
);

// Assert
result.Should().Be(result);
result.Should().Be(_response);
_mockValidator.Verify(x => x.Validate(
It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
}
Expand Down Expand Up @@ -96,16 +96,16 @@ public async Task Handle_WhenSingleValidatorFails_ShouldThrowValidationException
);

// Assert
await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();

var thrownException = await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();
thrownException.Which.Messages.Should().HaveCount(2);
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Name" && e.ErrorMessage == "Name is required");
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Age" && e.ErrorMessage == "Age must be between 20 and 50");

_mockValidator.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));

thrownException.Which.Details.Should().NotBeNull();
thrownException.Which.Details.Should().HaveCount(2);
thrownException.Which.Details.Should().ContainKey("Name");
thrownException.Which.Details.Should().ContainKey("Age");
thrownException.Which.Details["Name"].Should().Equal("Name is required");
thrownException.Which.Details["Age"].Should().Equal("Age must be between 20 and 50");

_mockValidator.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
}

[Trait("Category", "Unit")]
Expand Down Expand Up @@ -138,19 +138,23 @@ public async Task Handle_WhenMultipleValidatorsFail_ShouldThrowValidationExcepti
);

// Assert
await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();

var thrownException = await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();
thrownException.Which.Messages.Should().HaveCount(3);
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Name" && e.ErrorMessage == "Name is required");
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Age" && e.ErrorMessage == "Age must be positive");
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Name" && e.ErrorMessage == "Name must be unique");

mockValidator1.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));
mockValidator2.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));

thrownException.Which.Details.Should().NotBeNull();
thrownException.Which.Details.Should().HaveCount(2);
thrownException.Which.Details.Should().ContainKey("Name");
thrownException.Which.Details.Should().ContainKey("Age");

// Name tem 2 erros agrupados
thrownException.Which.Details["Name"].Should().HaveCount(2);
thrownException.Which.Details["Name"].Should().Contain("Name is required");
thrownException.Which.Details["Name"].Should().Contain("Name must be unique");

// Age tem 1 erro
thrownException.Which.Details["Age"].Should().Equal("Age must be positive");

mockValidator1.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
mockValidator2.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
}

[Trait("Category", "Unit")]
Expand All @@ -162,7 +166,6 @@ public async Task Handle_WhenCollectingErrorsFromAllValidators_ShouldThrowSingle
var mockValidator2 = new Mock<IValidator<RequestTest>>();
var mockValidator3 = new Mock<IValidator<RequestTest>>();

// First validator fails
mockValidator1.Setup(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()))
.Returns(new ValidationResult([
new ValidationFailure("Name", "Name is required")
Expand All @@ -187,18 +190,18 @@ public async Task Handle_WhenCollectingErrorsFromAllValidators_ShouldThrowSingle
);

// Assert
await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();

var thrownException = await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();
thrownException.Which.Messages.Should().HaveCount(2);
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Name" && e.ErrorMessage == "Name is required");
thrownException.Which.Messages.Should()
.Contain(e => e.PropertyName == "Age" && e.ErrorMessage == "Age must be positive");

mockValidator1.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));
mockValidator2.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));
mockValidator3.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Exactly(2));

thrownException.Which.Details.Should().NotBeNull();
thrownException.Which.Details.Should().HaveCount(2);
thrownException.Which.Details.Should().ContainKey("Name");
thrownException.Which.Details.Should().ContainKey("Age");
thrownException.Which.Details["Name"].Should().Equal("Name is required");
thrownException.Which.Details["Age"].Should().Equal("Age must be positive");

mockValidator1.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
mockValidator2.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
mockValidator3.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
}

[Trait("Category", "Unit")]
Expand Down Expand Up @@ -230,10 +233,15 @@ public async Task Handle_WhenSomeValidatorsFail_ShouldValidateAllValidators()
);

// Assert
await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();

var thrownException = await exception.Should().ThrowExactlyAsync<ApplicationLayer.ValidationException>();
// Verifica que TODOS os validators foram chamados
mockValidator1.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);
mockValidator2.Verify(x => x.Validate(It.IsAny<ValidationContext<RequestTest>>()), Times.Once);

// Verifica que os erros foram agrupados corretamente
thrownException.Which.Details.Should().HaveCount(2);
thrownException.Which.Details.Should().ContainKey("Name");
thrownException.Which.Details.Should().ContainKey("Age");
}
}
Loading