Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add support to multiple HTTP response status codes πŸ§‘β€πŸ’» #16

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ services

Or use [automatic registration](https://docs.fluentvalidation.net/en/latest/di.html#automatic-registration).

## Custom Status Code

Currently, `JSM.FluentValidation.AspNet.AsyncFilter` supports the following status codes:

- 400, Bad Request
- 403, Forbidden (`ErrorCode.Forbidden`)
- 404, Not Found (`ErrorCode.NotFound`)

By default, every client error will return a 400 status code (Bad Request). If you want to customize the response, use FluentValidation's [WithErrorCode()](https://docs.fluentvalidation.net/en/latest/error-codes.html):

```c#
RuleFor(user => user)
.Must(user => user.Id != "321")
.WithMessage("Insufficient rights to access this resource")
.WithErrorCode(ErrorCode.Forbidden);
```

## Customization

If also possible to apply the filter only to controllers that contains the [`ApiControllerAttribute`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.apicontrollerattribute).
Expand Down
28 changes: 28 additions & 0 deletions src/ErrorResponse/ErrorCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Collections.Generic;

namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
/// <summary>
/// Defines what HTTP status code should be returned. Use it with the `WithErrorCode` extension method.
/// </summary>
public static class ErrorCode
{
/// <summary>
/// 401 HTTP status code as per RFC 2616
/// </summary>
public const string Unauthorized = "UNAUTHORIZED_ERROR";

/// <summary>
/// 403 HTTP status code as per RFC 2616
/// </summary>
public const string Forbidden = "FORBIDDEN_ERROR";

/// <summary>
/// 404 HTTP status code as per RFC 2616
/// </summary>
public const string NotFound = "NOT_FOUND_ERROR";

internal static readonly HashSet<string> AvailableCodes = new HashSet<string>()
{Unauthorized, Forbidden, NotFound};
}
}
44 changes: 44 additions & 0 deletions src/ErrorResponse/ErrorResponseFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
internal static class ErrorResponseFactory
{
public static TraceableProblemDetails CreateErrorResponse(ModelStateDictionary modelState,
string traceparent)
{
if (modelState[ErrorCode.Unauthorized] is not null)
return new UnauthorizedResponse(
modelState[ErrorCode.Unauthorized]?.Errors.FirstOrDefault()?.ErrorMessage ??
string.Empty, traceparent);

if (modelState[ErrorCode.Forbidden] is not null)
return new ForbiddenResponse(
modelState[ErrorCode.Forbidden]?.Errors.FirstOrDefault()?.ErrorMessage ??
string.Empty, traceparent);

return new NotFoundResponse(
modelState[ErrorCode.NotFound]?.Errors.FirstOrDefault()?.ErrorMessage ??
string.Empty,
traceparent);
}

public static HttpStatusCode GetResponseStatusCode(ModelStateDictionary modelState)
{
if (modelState[ErrorCode.Unauthorized] is not null)
return HttpStatusCode.Unauthorized;

if (modelState[ErrorCode.Forbidden] is not null)
return HttpStatusCode.Forbidden;

return modelState[ErrorCode.NotFound] is not null
? HttpStatusCode.NotFound
: HttpStatusCode.BadRequest;
}
}
}
14 changes: 14 additions & 0 deletions src/ErrorResponse/ForbiddenResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
internal class ForbiddenResponse : TraceableProblemDetails
{
public ForbiddenResponse(string message, string traceparent)
{
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.3";
Title = ErrorCode.Forbidden;
Status = 403;
Detail = message;
TraceId = traceparent;
}
}
}
17 changes: 17 additions & 0 deletions src/ErrorResponse/NotFoundResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
internal class NotFoundResponse : TraceableProblemDetails
{
public NotFoundResponse(string message, string traceparent)
{
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4";
Title = ErrorCode.NotFound;
Status = 404;
Detail = message;
TraceId = traceparent;
}
}
}
14 changes: 14 additions & 0 deletions src/ErrorResponse/TraceableProblemDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
internal abstract class TraceableProblemDetails : ProblemDetails
{
/// <summary>
/// A unique identifier responsible to describe the incoming request.
/// </summary>
[JsonPropertyName("traceId")]
public string TraceId { get; set; }
}
}
14 changes: 14 additions & 0 deletions src/ErrorResponse/UnauthorizedResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse
{
internal class UnauthorizedResponse : TraceableProblemDetails
{
public UnauthorizedResponse(string message, string traceparent)
{
Type = "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1";
Title = ErrorCode.Unauthorized;
Status = 401;
Detail = message;
TraceId = traceparent;
}
}
}
1 change: 0 additions & 1 deletion src/JSM.FluentValidation.AspNet.AsyncFilter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.2.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.2.1" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
Expand Down
52 changes: 43 additions & 9 deletions src/ModelValidationAsyncActionFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
using Microsoft.Extensions.Options;
using System;
using System.Collections;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse;
using Microsoft.AspNetCore.Http;

namespace JSM.FluentValidation.AspNet.AsyncFilter
{
Expand Down Expand Up @@ -47,7 +52,8 @@ public ModelValidationAsyncActionFilter(
/// <summary>
/// Validates values before the controller's action is invoked (before the route is executed).
/// </summary>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
public async Task OnActionExecutionAsync(ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (ShouldIgnoreFilter(context))
{
Expand All @@ -59,8 +65,29 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE

if (!context.ModelState.IsValid)
{
_logger.LogDebug("The request has model state errors, returning an error response.");
context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
_logger.LogDebug(
"The request has model state errors, returning an error response");
var responseStatusCode =
ErrorResponseFactory.GetResponseStatusCode(context.ModelState);

// BadRequest responses will return the default response structure, only different
// status codes will be customized
if (responseStatusCode == HttpStatusCode.BadRequest)
{
context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
return;
}

var errorResponse =
ErrorResponseFactory.CreateErrorResponse(context.ModelState,
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier);

context.HttpContext.Response.StatusCode = (int) responseStatusCode;
context.HttpContext.Response.ContentType = "application/json";

var responseBody = JsonSerializer.Serialize(errorResponse);
await context.HttpContext.Response.WriteAsync(responseBody);

return;
}

Expand Down Expand Up @@ -97,22 +124,26 @@ private bool ShouldIgnoreFilter(ActionExecutingContext context)
return !hasApiControllerAttribute;
}

private async Task ValidateEnumerableObjectsAsync(object value, ModelStateDictionary modelState)
private async Task ValidateEnumerableObjectsAsync(object value,
ModelStateDictionary modelState)
{
var underlyingType = value.GetType().GenericTypeArguments[0];
var validator = GetValidator(underlyingType);

if (validator == null)
return;

foreach (var item in (IEnumerable)value)
foreach (var item in (IEnumerable) value)
{
if (item is null)
continue;
var context = new ValidationContext<object>(item);
var result = await validator.ValidateAsync(context);

result.AddToModelState(modelState, string.Empty);
var errorCode = result.Errors?.FirstOrDefault()?.ErrorCode;
Copy link
Member

@rafaelpadovezi rafaelpadovezi Feb 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: The order of the error entries affects the result? If the first validation that fails is a "normal" one and the second one has an .WithErrorCode(ErrorCode.Forbidden); what would be the the error code?

If so, maybe we should add this info to the README


result.AddToModelState(modelState,
ErrorCode.AvailableCodes.Contains(errorCode) ? errorCode : string.Empty);
}
}

Expand All @@ -125,18 +156,21 @@ private async Task ValidateAsync(object value, ModelStateDictionary modelState)

var context = new ValidationContext<object>(value);
var result = await validator.ValidateAsync(context);
result.AddToModelState(modelState, string.Empty);

var errorCode = result.Errors?.FirstOrDefault()?.ErrorCode;

result.AddToModelState(modelState,
ErrorCode.AvailableCodes.Contains(errorCode) ? errorCode : string.Empty);
}

private IValidator GetValidator(Type targetType)
{
var validatorType = typeof(IValidator<>).MakeGenericType(targetType);
var validator = (IValidator)_serviceProvider.GetService(validatorType);
var validator = (IValidator) _serviceProvider.GetService(validatorType);
return validator;
}

private static bool TypeIsEnumerable(Type type) =>
type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(type);
}
}

6 changes: 2 additions & 4 deletions tests/JSM.FluentValidation.AspNet.AsyncFilter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="FluentAssertions.Web" Version="1.0.133" />
<PackageReference Include="FluentValidation" Version="11.2.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.0" />
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="FluentAssertions.Web" Version="1.2.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="xunit" Version="2.4.1" />
Expand Down
8 changes: 4 additions & 4 deletions tests/ModelValidationActionFilter_CustomOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
ο»Ώusing FluentAssertions;
using JSM.FluentValidation.AspNet.AsyncFilter.Tests.Support;
using JSM.FluentValidation.AspNet.AsyncFilter.Tests.Support.Extensions;
using JSM.FluentValidation.AspNet.AsyncFilter.Tests.Support.Models;
using JSM.FluentValidation.AspNet.AsyncFilter.Tests.Support.Startups;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
using System.Net.Http.Json;

namespace JSM.FluentValidation.AspNet.AsyncFilter.Tests
{
Expand All @@ -25,7 +25,7 @@ public async Task OnActionExecutionAsync_ControllerDoesNotHaveApiControllerAttri

// Act
var response =
await Client.PostAsJsonAsync($"{ControllerWithoutApiAttributeEndpoint}/test-validator", payload);
await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{ControllerWithoutApiAttributeEndpoint}/test-validator", payload);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: It's realy a minor issue, but I didn't understand why changing the call to PostAsJsonAsync this way. Client.PostAsJsonAsync stopped working? If our custom extension is causing problems I think it's better to remove it since we don't need it anymore.


// Assert
response.Should().Be200Ok();
Expand All @@ -39,10 +39,10 @@ public async Task OnActionExecutionAsync_ControllerHasApiControllerAttribute_Ret

// Act
var response =
await Client.PostAsJsonAsync($"{ControllerWithApiAttributeEndpoint}/test-validator", payload);
await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{ControllerWithApiAttributeEndpoint}/test-validator", payload);

// Assert
response.Should().Be400BadRequest();
}
}
}
}
Loading