diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 502d678f0d..54fcf5afaf 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -125,6 +125,16 @@ public class JsonApiOptions /// </remarks> public bool EnableOperations { get; set; } + /// <summary> + /// Whether or not to validate model state. + /// </summary> + /// <example> + /// <code> + /// options.ValidateModelState = true; + /// </code> + /// </example> + public bool ValidateModelState { get; set; } + [Obsolete("JsonContract resolver can now be set on SerializerSettings.")] public IContractResolver JsonContractResolver { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 760f8f8d56..f4041e97d6 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -152,6 +153,8 @@ public virtual async Task<IActionResult> PostAsync([FromBody] T entity) if (!_jsonApiContext.Options.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) return Forbidden(); + if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) + return BadRequest(ModelState.ConvertToErrorCollection()); entity = await _create.CreateAsync(entity); @@ -164,6 +167,8 @@ public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] T entity) if (entity == null) return UnprocessableEntity(); + if (_jsonApiContext.Options.ValidateModelState && !ModelState.IsValid) + return BadRequest(ModelState.ConvertToErrorCollection()); var updatedEntity = await _update.UpdateAsync(id, entity); diff --git a/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs new file mode 100644 index 0000000000..8eb0fc95f7 --- /dev/null +++ b/src/JsonApiDotNetCore/Extensions/ModelStateExtensions.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.EntityFrameworkCore.Internal; + +namespace JsonApiDotNetCore.Extensions +{ + public static class ModelStateExtensions + { + public static ErrorCollection ConvertToErrorCollection(this ModelStateDictionary modelState) + { + ErrorCollection errors = new ErrorCollection(); + foreach (var entry in modelState) + { + if (!entry.Value.Errors.Any()) + continue; + foreach (var modelError in entry.Value.Errors) + { + errors.Errors.Add(new Error(400, entry.Key, modelError.ErrorMessage, modelError.Exception != null ? ErrorMeta.FromException(modelError.Exception) : null)); + } + } + return errors; + } + } +} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 75fc955402..cc337c28dc 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <VersionPrefix>2.3.2</VersionPrefix> + <VersionPrefix>2.3.3</VersionPrefix> <TargetFrameworks>$(NetStandardVersion)</TargetFrameworks> <AssemblyName>JsonApiDotNetCore</AssemblyName> <PackageId>JsonApiDotNetCore</PackageId> diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 9c59372846..d2d3ac8319 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -5,7 +5,10 @@ using Moq; using Xunit; using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; namespace UnitTests { @@ -143,6 +146,8 @@ public async Task PatchAsync_Calls_Service() const int id = 0; var resource = new Resource(); var serviceMock = new Mock<IUpdateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, update: serviceMock.Object); // act @@ -153,6 +158,47 @@ public async Task PatchAsync_Calls_Service() VerifyApplyContext(); } + [Fact] + public async Task PatchAsync_ModelStateInvalid_ValidateModelStateDisbled() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock<IUpdateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); + var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, update: serviceMock.Object); + + // act + var response = await controller.PatchAsync(id, resource); + + // assert + serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny<Resource>()), Times.Once); + VerifyApplyContext(); + Assert.IsNotType<BadRequestObjectResult>(response); + } + + [Fact] + public async Task PatchAsync_ModelStateInvalid_ValidateModelStateEnabled() + { + // arrange + const int id = 0; + var resource = new Resource(); + var serviceMock = new Mock<IUpdateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions{ValidateModelState = true}); + var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, update: serviceMock.Object); + controller.ModelState.AddModelError("Id", "Failed Validation"); + + // act + var response = await controller.PatchAsync(id, resource); + + // assert + serviceMock.Verify(m => m.UpdateAsync(id, It.IsAny<Resource>()), Times.Never); + Assert.IsType<BadRequestObjectResult>(response); + Assert.IsType<ErrorCollection>(((BadRequestObjectResult) response).Value); + } + [Fact] public async Task PatchAsync_Throws_405_If_No_Service() { @@ -168,6 +214,67 @@ public async Task PatchAsync_Throws_405_If_No_Service() Assert.Equal(405, exception.GetStatusCode()); } + [Fact] + public async Task PostAsync_Calls_Service() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock<ICreateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions()); + var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, create: serviceMock.Object); + serviceMock.Setup(m => m.CreateAsync(It.IsAny<Resource>())).ReturnsAsync(resource); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext {HttpContext = new DefaultHttpContext()}; + + // act + await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny<Resource>()), Times.Once); + VerifyApplyContext(); + } + + [Fact] + public async Task PostAsync_ModelStateInvalid_ValidateModelStateDisabled() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock<ICreateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = false }); + var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, create: serviceMock.Object); + serviceMock.Setup(m => m.CreateAsync(It.IsAny<Resource>())).ReturnsAsync(resource); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() }; + + // act + var response = await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny<Resource>()), Times.Once); + VerifyApplyContext(); + Assert.IsNotType<BadRequestObjectResult>(response); + } + + [Fact] + public async Task PostAsync_ModelStateInvalid_ValidateModelStateEnabled() + { + // arrange + var resource = new Resource(); + var serviceMock = new Mock<ICreateService<Resource>>(); + _jsonApiContextMock.Setup(a => a.ApplyContext<Resource>(It.IsAny<BaseJsonApiController<Resource>>())).Returns(_jsonApiContextMock.Object); + _jsonApiContextMock.SetupGet(a => a.Options).Returns(new JsonApiOptions { ValidateModelState = true }); + var controller = new BaseJsonApiController<Resource>(_jsonApiContextMock.Object, create: serviceMock.Object); + controller.ModelState.AddModelError("Id", "Failed Validation"); + + // act + var response = await controller.PostAsync(resource); + + // assert + serviceMock.Verify(m => m.CreateAsync(It.IsAny<Resource>()), Times.Never); + Assert.IsType<BadRequestObjectResult>(response); + Assert.IsType<ErrorCollection>(((BadRequestObjectResult)response).Value); + } + [Fact] public async Task PatchRelationshipsAsync_Calls_Service() {