diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index f5344dd32d..f241e9876c 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -112,3 +112,23 @@ public class Foo : Identifiable } } ``` + +# Custom Validators + +Attributes can be marked with custom validators. + +## RequiredOnPost Validator Attribute + +The 'RequiredOnPost' custom validator attribute can be marked on properties to specify if a value is required on POST requests. This allows the property to be excluded on PATCH requests, making partial patching possible. + +The 'RequiredOnPost' custom validator attribute accepts a bool to specify if empty strings are allowed on that property. The default for 'AllowEmptyStrings' is false. + +If a PATCH request contains a property assigned the 'RequiredOnPost'custom validator attribute, the requirements of the validator are verified against the patched value, which include that the value is not null and not empty if 'AllowEmptyStrings' is set to false. + +```c# +public class Person : Identifiable +{ + [RequiredOnPost] + public string FirstName { get; set; } +} +``` diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index 01b0d1e352..9df67889fd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Models; @@ -7,6 +8,7 @@ namespace JsonApiDotNetCoreExample.Models public sealed class Article : Identifiable { [Attr] + [RequiredOnPost(true)] public string Name { get; set; } [HasOne] diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 3cab898881..4b7804cbb2 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -47,6 +47,7 @@ public async Task ReadAsync(InputFormatterContext context) object model; try { + _deserializer.ModelState = context.ModelState; model = _deserializer.Deserialize(body); } catch (InvalidRequestBodyException exception) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9a1e0c0104..7786ae80b3 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -24,6 +24,7 @@ + diff --git a/src/JsonApiDotNetCore/Models/CustomValidators/RequiredOnPostAttribute.cs b/src/JsonApiDotNetCore/Models/CustomValidators/RequiredOnPostAttribute.cs new file mode 100644 index 0000000000..fa03d29005 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/CustomValidators/RequiredOnPostAttribute.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Models +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class RequiredOnPostAttribute : ValidationAttribute + { + public bool AllowEmptyStrings { get; set; } + + /// + /// Validates that the value is not null or empty on POST operations. + /// + /// Allow empty strings + public RequiredOnPostAttribute(bool allowEmptyStrings = false) + { + AllowEmptyStrings = allowEmptyStrings; + } + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + var httpContextAccessor = (IHttpContextAccessor)validationContext.GetRequiredService(typeof(IHttpContextAccessor)); + if (httpContextAccessor.HttpContext.Request.Method == "POST") + { + var additionaError = string.Empty; + if (!AllowEmptyStrings) + { + additionaError = " or empty"; + } + + if (ErrorMessage == null) + { + ErrorMessage = $"The field {validationContext.MemberName} is required and cannot be null{additionaError}."; + } + + if (value == null) + { + return new ValidationResult(ErrorMessage); + } + + if (!AllowEmptyStrings) + { + if (value is string stringValue && string.IsNullOrEmpty(stringValue)) + { + return new ValidationResult(ErrorMessage); + } + } + } + return ValidationResult.Success; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs index 680391a755..ed02e1f9cb 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace JsonApiDotNetCore.Serialization.Server { @@ -7,6 +8,8 @@ namespace JsonApiDotNetCore.Serialization.Server /// public interface IJsonApiDeserializer { + public ModelStateDictionary ModelState { get; set; } + /// /// Deserializes JSON in to a and constructs entities /// from . diff --git a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs index c17ac03473..cec049b049 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs @@ -1,8 +1,9 @@ -using System; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace JsonApiDotNetCore.Serialization.Server { @@ -13,6 +14,8 @@ public class RequestDeserializer : BaseDocumentParser, IJsonApiDeserializer { private readonly ITargetedFields _targetedFields; + public ModelStateDictionary ModelState { get; set; } + public RequestDeserializer(IResourceContextProvider contextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields) : base(contextProvider, resourceFactory) { @@ -36,16 +39,47 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f { if (field is AttrAttribute attr) { - if (attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate)) + if (!attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate)) { - _targetedFields.Attributes.Add(attr); + throw new InvalidRequestBodyException( + "Changing the value of the requested attribute is not allowed.", + $"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null); } - else + + + var requiredOnPost = Attribute.GetCustomAttribute(attr.PropertyInfo, typeof(RequiredOnPostAttribute)); + if (requiredOnPost != null) { - throw new InvalidRequestBodyException( - "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null); + var requiredOnPostAttribute = (RequiredOnPostAttribute)requiredOnPost; + var errorMessage = requiredOnPostAttribute.ErrorMessage; + if (errorMessage == null) + { + errorMessage = $"The field {attr.PropertyInfo.Name} is required and cannot be null."; + } + + if (attr.GetValue(entity) == null) + { + if (ModelState != null) + { + ModelState.AddModelError(attr.PropertyInfo.Name, errorMessage); + } + } + + if (attr.GetValue(entity) is string stringValue && string.IsNullOrEmpty(stringValue)) + { + if (!requiredOnPostAttribute.AllowEmptyStrings) + { + errorMessage = $"The field {attr.PropertyInfo.Name} is required and cannot be null."; + if (ModelState != null) + { + ModelState.AddModelError(attr.PropertyInfo.Name, errorMessage); + } + } + } } + + _targetedFields.Attributes.Add(attr); + } else if (field is RelationshipAttribute relationship) _targetedFields.Relationships.Add(relationship); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 94cffa66bb..b41a47501b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -294,6 +294,10 @@ public async Task Can_Create_Many_To_Many() data = new { type = "articles", + attributes = new Dictionary + { + {"name", "An article with relationships"} + }, relationships = new Dictionary { { "author", new { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 41ea2d9d45..1eff7503b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -82,7 +82,7 @@ public async Task Can_Create_User_With_Password() var serializer = _fixture.GetSerializer(p => new { p.Password, p.Username }); - + var httpMethod = new HttpMethod("POST"); var route = "/api/v1/users"; @@ -609,5 +609,381 @@ public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); Assert.Null(errorDocument.Errors[0].Detail); } + + [Fact] + public async Task Create_Article_With_RequiredOnPost_Name_Attribute_Succeeds() + { + // Arrange + string name = "Article Title"; + var context = _fixture.GetService(); + var author = new Author(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(new HttpMethod("POST"), route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"name", name} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; + Assert.NotNull(articleResponse); + + var persistedArticle = await _fixture.Context.Articles + .SingleAsync(a => a.Id == articleResponse.Id); + + Assert.Equal(name, persistedArticle.Name); + } + + [Fact] + public async Task Create_Article_With_RequiredOnPost_Name_Attribute_Empty_Succeeds() + { + // Arrange + string name = string.Empty; + var context = _fixture.GetService(); + var author = new Author(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(new HttpMethod("POST"), route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"name", name} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; + Assert.NotNull(articleResponse); + + var persistedArticle = await _fixture.Context.Articles + .SingleAsync(a => a.Id == articleResponse.Id); + + Assert.Equal(name, persistedArticle.Name); + } + + [Fact] + public async Task Create_Article_With_RequiredOnPost_Name_Attribute_Explicitly_Null_Fails() + { + // Arrange + var context = _fixture.GetService(); + var author = new Author(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(new HttpMethod("POST"), route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"name", null} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The field Name is required and cannot be null.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Create_Article_With_RequiredOnPost_Name_Attribute_Missing_Fails() + { + // Arrange + var context = _fixture.GetService(); + var author = new Author(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(new HttpMethod("POST"), route); + var content = new + { + data = new + { + type = "articles", + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The field Name is required and cannot be null.", errorDocument.Errors[0].Detail); + } + + + [Fact] + public async Task Update_Article_With_RequiredOnPost_Name_Attribute_Succeeds() + { + // Arrange + var name = "Article Name"; + var context = _fixture.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"name", name} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + _fixture.ReloadDbContext(); + var persistedArticle = await _fixture.Context.Articles + .SingleOrDefaultAsync(a => a.Id == article.Id); + + var updatedName = persistedArticle.Name; + Assert.Equal(name, updatedName); + } + + [Fact] + public async Task Update_Article_With_RequiredOnPost_Name_Attribute_Missing_Succeeds() + { + // Arrange + var context = _fixture.GetService(); + var tag = _tagFaker.Generate(); + var article = _articleFaker.Generate(); + context.Tags.Add(tag); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + relationships = new Dictionary + { + { "tags", new + { + data = new [] + { + new + { + type = "tags", + id = tag.StringId + } + } + } + } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Update_Article_With_RequiredOnPost_Name_Attribute_Explicitly_Null_Fails() + { + // Arrange + var context = _fixture.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"name", null} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The field Name is required and cannot be null.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Update_Article_With_RequiredOnPost_AllowEmptyString_True_Name_Attribute_Empty_Succeeds() + { + // Arrange + var context = _fixture.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(new HttpMethod("PATCH"), route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"name", ""} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + _fixture.ReloadDbContext(); + var persistedArticle = await _fixture.Context.Articles + .SingleOrDefaultAsync(a => a.Id == article.Id); + + var updatedName = persistedArticle.Name; + Assert.Equal("", updatedName); + } + } }