diff --git a/README.md b/README.md index 7b24146..99ec4fd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/ErrorResponse/ErrorCode.cs b/src/ErrorResponse/ErrorCode.cs new file mode 100644 index 0000000..655a429 --- /dev/null +++ b/src/ErrorResponse/ErrorCode.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse +{ + /// + /// Defines what HTTP status code should be returned. Use it with the `WithErrorCode` extension method. + /// + public static class ErrorCode + { + /// + /// 401 HTTP status code as per RFC 2616 + /// + public const string Unauthorized = "UNAUTHORIZED_ERROR"; + + /// + /// 403 HTTP status code as per RFC 2616 + /// + public const string Forbidden = "FORBIDDEN_ERROR"; + + /// + /// 404 HTTP status code as per RFC 2616 + /// + public const string NotFound = "NOT_FOUND_ERROR"; + + internal static readonly HashSet AvailableCodes = new HashSet() + {Unauthorized, Forbidden, NotFound}; + } +} diff --git a/src/ErrorResponse/ErrorResponseFactory.cs b/src/ErrorResponse/ErrorResponseFactory.cs new file mode 100644 index 0000000..f9d7c89 --- /dev/null +++ b/src/ErrorResponse/ErrorResponseFactory.cs @@ -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; + } + } +} diff --git a/src/ErrorResponse/ForbiddenResponse.cs b/src/ErrorResponse/ForbiddenResponse.cs new file mode 100644 index 0000000..b4ce659 --- /dev/null +++ b/src/ErrorResponse/ForbiddenResponse.cs @@ -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; + } + } +} diff --git a/src/ErrorResponse/NotFoundResponse.cs b/src/ErrorResponse/NotFoundResponse.cs new file mode 100644 index 0000000..ab4d3ad --- /dev/null +++ b/src/ErrorResponse/NotFoundResponse.cs @@ -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; + } + } +} diff --git a/src/ErrorResponse/TraceableProblemDetails.cs b/src/ErrorResponse/TraceableProblemDetails.cs new file mode 100644 index 0000000..11ac28e --- /dev/null +++ b/src/ErrorResponse/TraceableProblemDetails.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse +{ + internal abstract class TraceableProblemDetails : ProblemDetails + { + /// + /// A unique identifier responsible to describe the incoming request. + /// + [JsonPropertyName("traceId")] + public string TraceId { get; set; } + } +} diff --git a/src/ErrorResponse/UnauthorizedResponse.cs b/src/ErrorResponse/UnauthorizedResponse.cs new file mode 100644 index 0000000..7fe59cf --- /dev/null +++ b/src/ErrorResponse/UnauthorizedResponse.cs @@ -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; + } + } +} diff --git a/src/JSM.FluentValidation.AspNet.AsyncFilter.csproj b/src/JSM.FluentValidation.AspNet.AsyncFilter.csproj index bb97fa3..d8f72cb 100644 --- a/src/JSM.FluentValidation.AspNet.AsyncFilter.csproj +++ b/src/JSM.FluentValidation.AspNet.AsyncFilter.csproj @@ -17,7 +17,6 @@ - diff --git a/src/ModelValidationAsyncActionFilter.cs b/src/ModelValidationAsyncActionFilter.cs index 5382baf..38aa054 100644 --- a/src/ModelValidationAsyncActionFilter.cs +++ b/src/ModelValidationAsyncActionFilter.cs @@ -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 { @@ -47,7 +52,8 @@ public ModelValidationAsyncActionFilter( /// /// Validates values before the controller's action is invoked (before the route is executed). /// - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + public async Task OnActionExecutionAsync(ActionExecutingContext context, + ActionExecutionDelegate next) { if (ShouldIgnoreFilter(context)) { @@ -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; } @@ -97,7 +124,8 @@ 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); @@ -105,14 +133,17 @@ private async Task ValidateEnumerableObjectsAsync(object value, ModelStateDictio 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(item); 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); } } @@ -125,13 +156,17 @@ private async Task ValidateAsync(object value, ModelStateDictionary modelState) var context = new ValidationContext(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; } @@ -139,4 +174,3 @@ private static bool TypeIsEnumerable(Type type) => type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(type); } } - diff --git a/tests/JSM.FluentValidation.AspNet.AsyncFilter.Tests.csproj b/tests/JSM.FluentValidation.AspNet.AsyncFilter.Tests.csproj index 8eb0541..6940fea 100644 --- a/tests/JSM.FluentValidation.AspNet.AsyncFilter.Tests.csproj +++ b/tests/JSM.FluentValidation.AspNet.AsyncFilter.Tests.csproj @@ -7,10 +7,8 @@ - - - - + + diff --git a/tests/ModelValidationActionFilter_CustomOptionsTests.cs b/tests/ModelValidationActionFilter_CustomOptionsTests.cs index 676ac50..02a040f 100644 --- a/tests/ModelValidationActionFilter_CustomOptionsTests.cs +++ b/tests/ModelValidationActionFilter_CustomOptionsTests.cs @@ -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 { @@ -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); // Assert response.Should().Be200Ok(); @@ -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(); } } -} \ No newline at end of file +} diff --git a/tests/ModelValidationAsyncActionFilterTests.cs b/tests/ModelValidationAsyncActionFilterTests.cs index 21338a1..abefe12 100644 --- a/tests/ModelValidationAsyncActionFilterTests.cs +++ b/tests/ModelValidationAsyncActionFilterTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using System.Collections.Generic; using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; using Xunit; @@ -26,96 +27,106 @@ public class ModelValidationActionFilterTests : WebAppFixture { - { "Text", new[] { "Text can't be null" } } + {"Text", new[] {"Text can't be null"}} }); } [Theory(DisplayName = "Should return bad request when payload is invalid")] [InlineData(ControllerWithApiAttributeEndpoint)] [InlineData(ControllerWithoutApiAttributeEndpoint)] - public async Task OnActionExecutionAsync_PayloadIsInvalid_ReturnBadRequest(string controller) + public async Task OnActionExecutionAsync_PayloadIsInvalid_ReturnBadRequest( + string controller) { // Arrange - var payload = new TestPayload { Text = "" }; + var payload = new TestPayload {Text = ""}; // Act - var response = await Client.PostAsJsonAsync($"{controller}/test-validator", payload); + var response = await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/test-validator", payload); // Assert response.Should().Be400BadRequest(); - var responseDetails = await response.Content.ReadFromJsonAsync(); + var responseDetails = + await response.Content.ReadFromJsonAsync(); responseDetails.Title.Should().Be("One or more validation errors occurred."); responseDetails.Errors.Should().BeEquivalentTo(new Dictionary { - { "Text", new[] { "Text can't be null" } } + {"Text", new[] {"Text can't be null"}} }); } [Theory(DisplayName = "Should return bad request when objects in collection are invalid")] [InlineData(ControllerWithApiAttributeEndpoint)] [InlineData(ControllerWithoutApiAttributeEndpoint)] - public async Task OnActionExecutionAsync_ObjectsInCollectionInValid_ReturnBadRequest(string controller) + public async Task OnActionExecutionAsync_ObjectsInCollectionInValid_ReturnBadRequest( + string controller) { // Arrange var payload = new[] { - new TestPayload { Text = "" }, - new TestPayload { Text = "" } + new TestPayload {Text = ""}, + new TestPayload {Text = ""} }; // Act - var response = await Client.PostAsJsonAsync($"{controller}/test-validator-collection", payload); + var response = + await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/test-validator-collection", payload); // Assert response.Should().Be400BadRequest(); - var responseDetails = await response.Content.ReadFromJsonAsync(); + var responseDetails = + await response.Content.ReadFromJsonAsync(); responseDetails.Title.Should().Be("One or more validation errors occurred."); responseDetails.Errors.Should().BeEquivalentTo(new Dictionary { - { "Text", new[] { "Text can't be null", "Text can't be null" } } + {"Text", new[] {"Text can't be null", "Text can't be null"}} }); } [Theory(DisplayName = "Should return ok when collection and objects are valid")] [InlineData(ControllerWithApiAttributeEndpoint)] [InlineData(ControllerWithoutApiAttributeEndpoint)] - public async Task OnActionExecutionAsync_ObjectsInCollectionAreValid_ReturnOk(string controller) + public async Task OnActionExecutionAsync_ObjectsInCollectionAreValid_ReturnOk( + string controller) { // Arrange var payload = new[] { - new TestPayload { Text = "Test" }, - new TestPayload { Text = "Test" } + new TestPayload {Text = "Test"}, + new TestPayload {Text = "Test"} }; - + // Act - var response = await Client.PostAsJsonAsync($"{controller}/test-validator-collection", payload); + var response = + await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/test-validator-collection", payload); // Assert response @@ -126,52 +137,58 @@ public async Task OnActionExecutionAsync_ObjectsInCollectionAreValid_ReturnOk(st [Theory(DisplayName = "Should return bad request when collection is valid")] [InlineData(ControllerWithApiAttributeEndpoint)] [InlineData(ControllerWithoutApiAttributeEndpoint)] - public async Task OnActionExecutionAsync_CollectionIsInvalid_ReturnBadRequest(string controller) + public async Task OnActionExecutionAsync_CollectionIsInvalid_ReturnBadRequest( + string controller) { // Arrange var payload = new[] { - new TestPayload { Text = "Test" }, - new TestPayload { Text = "Test" }, - new TestPayload { Text = "Test" } + new TestPayload {Text = "Test"}, + new TestPayload {Text = "Test"}, + new TestPayload {Text = "Test"} }; // Act - var response = await Client.PostAsJsonAsync($"{controller}/test-validator-collection", payload); + var response = + await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/test-validator-collection", payload); // Assert response.Should().Be400BadRequest(); - var responseDetails = await response.Content.ReadFromJsonAsync(); + var responseDetails = + await response.Content.ReadFromJsonAsync(); responseDetails.Title.Should().Be("One or more validation errors occurred."); responseDetails.Errors.Should().BeEquivalentTo(new Dictionary { - { "Count", new[] { "Should be less than 3!" } } + {"Count", new[] {"Should be less than 3!"}} }); } [Theory(DisplayName = "Should not validate objects when collection is invalid")] [InlineData(ControllerWithApiAttributeEndpoint)] [InlineData(ControllerWithoutApiAttributeEndpoint)] - public async Task OnActionExecutionAsync_CollectionIsInvalid_ShouldNotValidateObjects(string controller) + public async Task OnActionExecutionAsync_CollectionIsInvalid_ShouldNotValidateObjects( + string controller) { // Arrange var payload = new[] { - new TestPayload { Text = "" }, - new TestPayload { Text = "" }, - new TestPayload { Text = "" } + new TestPayload {Text = ""}, + new TestPayload {Text = ""}, + new TestPayload {Text = ""} }; // Act - var response = await Client.PostAsJsonAsync($"{controller}/test-validator-collection", payload); + var response = + await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/test-validator-collection", payload); // Assert response.Should().Be400BadRequest(); - var responseDetails = await response.Content.ReadFromJsonAsync(); + var responseDetails = + await response.Content.ReadFromJsonAsync(); responseDetails.Title.Should().Be("One or more validation errors occurred."); responseDetails.Errors.Should().BeEquivalentTo(new Dictionary { - { "Count", new[] { "Should be less than 3!" } } + {"Count", new[] {"Should be less than 3!"}} }); } @@ -181,13 +198,119 @@ public async Task OnActionExecutionAsync_CollectionIsInvalid_ShouldNotValidateOb public async Task OnActionExecutionAsync_ClassWithoutValidator_ReturnOk(string controller) { // Arrange - var payload = new TestPayloadWithoutValidation { Text = "" }; + var payload = new TestPayloadWithoutValidation {Text = ""}; + + // Act + var response = + await HttpClientJsonExtensions.PostAsJsonAsync(Client, $"{controller}/without-validation", payload); + + // Assert + response.Should().Be200Ok(); + } + + [Theory(DisplayName = "Should return OK when the request met all requirements")] + [InlineData(ControllerWithApiAttributeEndpoint)] + [InlineData(ControllerWithoutApiAttributeEndpoint)] + public async Task OnActionExecutionAsync_RequestMetAllRequirements_ReturnOk( + string controller) + { + // Arrange + var payload = new TestUser {Id = "123"}; // Act - var response = await Client.PostAsJsonAsync($"{controller}/without-validation", payload); + var response = + await Client.GetAsync($"{controller}/user-test-validator?id={payload.Id}"); // Assert response.Should().Be200Ok(); } + + [Theory(DisplayName = + "Should return Not Found when the request has an input that doesn't exist")] + [InlineData(ControllerWithApiAttributeEndpoint)] + [InlineData(ControllerWithoutApiAttributeEndpoint)] + public async Task OnActionExecutionAsync_RequestWithNonexistentInput_ReturnNotFound( + string controller) + { + // Arrange + var payload = new TestUser {Id = "333"}; + + // Act + var response = + await Client.GetAsync($"{controller}/user-test-validator?id={payload.Id}"); + + // Assert + response.Should().Be404NotFound().And.BeAs( + new + { + type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4", + title = "NOT_FOUND_ERROR", + status = 404, + traceId = "0HMH5DLVSLJDP", + detail = "User not found" + }, + options => options.Excluding(source => source.traceId) + ); + } + + [Theory(DisplayName = + "Should return Forbidden when the request has an input with insufficient rights")] + [InlineData(ControllerWithApiAttributeEndpoint)] + [InlineData(ControllerWithoutApiAttributeEndpoint)] + public async Task OnActionExecutionAsync_RequestWithInsufficientRightsInput_ReturnForbidden( + string controller) + { + // Arrange + var payload = new TestUser {Id = "321"}; + + // Act + var response = + await Client.GetAsync($"{controller}/user-test-validator?id={payload.Id}"); + + var responseBla = await response.Content.ReadAsStringAsync(); + + // Assert + response.Should().Be403Forbidden().And.BeAs( + new + { + type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.3", + title = "FORBIDDEN_ERROR", + status = 403, + traceId = "", + detail = "Insufficient rights to access this resource" + }, + options => options.Excluding(source => source.traceId) + ); + } + + [Theory(DisplayName = + "Should return Unauthorized when the user is not authorized to request that resource")] + [InlineData(ControllerWithApiAttributeEndpoint)] + [InlineData(ControllerWithoutApiAttributeEndpoint)] + public async Task OnActionExecutionAsync_RequestWithUnauthorizedUser_ReturnUnauthorized( + string controller) + { + // Arrange + var payload = new TestUser {Id = "432"}; + + // Act + var response = + await Client.GetAsync($"{controller}/user-test-validator?id={payload.Id}"); + + var responseBla = await response.Content.ReadAsStringAsync(); + + // Assert + response.Should().Be401Unauthorized().And.BeAs( + new + { + type = "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1", + title = "UNAUTHORIZED_ERROR", + status = 401, + traceId = "", + detail = "Unauthorized user" + }, + options => options.Excluding(source => source.traceId) + ); + } } } diff --git a/tests/Support/Controllers/WithApiAttributeController.cs b/tests/Support/Controllers/WithApiAttributeController.cs index f78dc62..f948041 100644 --- a/tests/Support/Controllers/WithApiAttributeController.cs +++ b/tests/Support/Controllers/WithApiAttributeController.cs @@ -18,5 +18,11 @@ public class WithApiAttributeController : ControllerBase [HttpPost("without-validation")] public IActionResult Post([FromBody] TestPayloadWithoutValidation request) => Ok(); + + [HttpGet("user-test-validator")] + public IActionResult Get([FromQuery] TestUser request) + { + return Ok(); + } } -} \ No newline at end of file +} diff --git a/tests/Support/Controllers/WithoutApiAttributeController.cs b/tests/Support/Controllers/WithoutApiAttributeController.cs index 0f0d4fc..35e0b28 100644 --- a/tests/Support/Controllers/WithoutApiAttributeController.cs +++ b/tests/Support/Controllers/WithoutApiAttributeController.cs @@ -18,5 +18,8 @@ public class WithoutApiAttributeController : ControllerBase [HttpPost("without-validation")] public IActionResult Post([FromBody] TestPayloadWithoutValidation request) => Ok(); + + [HttpGet("user-test-validator")] + public IActionResult Get([FromQuery] TestUser request) => Ok(); } -} \ No newline at end of file +} diff --git a/tests/Support/Models/TestUser.cs b/tests/Support/Models/TestUser.cs new file mode 100644 index 0000000..57d3152 --- /dev/null +++ b/tests/Support/Models/TestUser.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentValidation; +using JSM.FluentValidation.AspNet.AsyncFilter.ErrorResponse; + +namespace JSM.FluentValidation.AspNet.AsyncFilter.Tests.Support.Models +{ + public class TestUser + { + public string Id { get; set; } + + public string Name { get; set; } + } + + public class TestUserValidator : AbstractValidator + { + public TestUserValidator() + { + RuleFor(user => user) + .Must(user => user.Id != "432") + .WithMessage("Unauthorized user") + .WithErrorCode(ErrorCode.Unauthorized); + + RuleFor(user => user) + .Must(HasAlreadyBeenRegistered) + .WithMessage("User not found") + .WithErrorCode(ErrorCode.NotFound); + + RuleFor(user => user) + .Must(user => user.Id != "321") + .WithMessage("Insufficient rights to access this resource") + .WithErrorCode(ErrorCode.Forbidden); + } + + bool HasAlreadyBeenRegistered(TestUser user) + { + var storedUsers = new List() + { + new TestUser() {Id = "123", Name = "John Doe"}, + new TestUser() {Id = "321", Name = "User with insufficient rights"}, + new TestUser() {Id = "432", Name = "Unauthorized user"} + }; + + return storedUsers.Any(x => x.Id == user.Id); + } + } +} diff --git a/tests/Support/Startups/BaseStartup.cs b/tests/Support/Startups/BaseStartup.cs index 67c921b..71a3915 100644 --- a/tests/Support/Startups/BaseStartup.cs +++ b/tests/Support/Startups/BaseStartup.cs @@ -10,7 +10,7 @@ public abstract class BaseStartup public virtual void ConfigureServices(IServiceCollection services) { services - .AddValidatorsFromAssemblyContaining();; + .AddValidatorsFromAssemblyContaining(); } public void Configure(IApplicationBuilder app)