From 78e4c41c5a73336e84703bd292390994f16ef30f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 15 Sep 2020 15:13:23 +0200 Subject: [PATCH 1/8] Refactored existing tests --- .../Models/Article.cs | 1 - .../JsonApiDotNetCoreExample/Models/Author.cs | 1 - .../JsonApiDotNetCoreExample/Models/Tag.cs | 2 - .../Acceptance/ModelStateValidationTests.cs | 559 ------------------ .../ModelStateValidation/Enterprise.cs | 25 + .../ModelStateValidation/EnterprisePartner.cs | 22 + .../EnterprisePartnerClassification.cs | 9 + .../EnterprisePartnersController.cs | 16 + .../EnterprisesController.cs | 16 + .../ModelStateDbContext.cs | 15 + .../ModelStateValidationStartup.cs | 21 + .../ModelStateValidationTests.cs | 316 ++++++++++ .../NoModelStateValidationTests.cs | 89 +++ .../ModelStateValidation/PostalAddress.cs | 27 + .../PostalAddressesController.cs | 16 + .../IntegrationTests/TestableStartup.cs | 16 +- 16 files changed, 581 insertions(+), 570 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index cdfc216110..455ed51039 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -8,7 +8,6 @@ namespace JsonApiDotNetCoreExample.Models public sealed class Article : Identifiable { [Attr] - [IsRequired(AllowEmptyStrings = true)] public string Caption { get; set; } [Attr] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs index 88793599e9..7280263468 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -11,7 +11,6 @@ public sealed class Author : Identifiable public string FirstName { get; set; } [Attr] - [IsRequired(AllowEmptyStrings = true)] public string LastName { get; set; } [Attr] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 21b74c0fd4..296fd559df 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -7,7 +6,6 @@ namespace JsonApiDotNetCoreExample.Models public class Tag : Identifiable { [Attr] - [RegularExpression(@"^\W$")] public string Name { get; set; } [Attr] diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs deleted file mode 100644 index 7a9d6f077b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ /dev/null @@ -1,559 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class ModelStateValidationTests : FunctionalTestCollection - { - private readonly Faker
_articleFaker; - private readonly Faker _authorFaker; - private readonly Faker _tagFaker; - - public ModelStateValidationTests(StandardApplicationFactory factory) - : base(factory) - { - var options = (JsonApiOptions) _factory.GetService(); - options.ValidateModelState = true; - - var context = _factory.GetService(); - - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); - - _tagFaker = new Faker() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - } - - [Fact] - public async Task When_posting_tag_with_invalid_name_it_must_fail() - { - // Arrange - var tag = new Tag - { - Name = "!@#$%^&*().-" - }; - - var serializer = GetSerializer(); - var content = serializer.Serialize(tag); - - var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/tags") - { - Content = new StringContent(content) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); - Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); - Assert.Equal("/data/attributes/name", errorDocument.Errors[0].Source.Pointer); - } - - [Fact] - public async Task When_posting_tag_with_invalid_name_without_model_state_validation_it_must_succeed() - { - // Arrange - var tag = new Tag - { - Name = "!@#$%^&*().-" - }; - - var serializer = GetSerializer(); - var content = serializer.Serialize(tag); - - var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/tags") - { - Content = new StringContent(content) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - var options = (JsonApiOptions)_factory.GetService(); - options.ValidateModelState = false; - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - } - - [Fact] - public async Task When_patching_tag_with_invalid_name_it_must_fail() - { - // Arrange - var existingTag = new Tag - { - Name = "Technology" - }; - - var context = _factory.GetService(); - context.Tags.Add(existingTag); - await context.SaveChangesAsync(); - - var updatedTag = new Tag - { - Id = existingTag.Id, - Name = "!@#$%^&*().-" - }; - - var serializer = GetSerializer(); - var content = serializer.Serialize(updatedTag); - - var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/tags/" + existingTag.StringId) - { - Content = new StringContent(content) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.UnprocessableEntity, errorDocument.Errors[0].StatusCode); - Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); - Assert.Equal("The field Name must match the regular expression '^\\W$'.", errorDocument.Errors[0].Detail); - Assert.Equal("/data/attributes/name", errorDocument.Errors[0].Source.Pointer); - } - - [Fact] - public async Task When_patching_tag_with_invalid_name_without_model_state_validation_it_must_succeed() - { - // Arrange - var existingTag = new Tag - { - Name = "Technology" - }; - - var context = _factory.GetService(); - context.Tags.Add(existingTag); - await context.SaveChangesAsync(); - - var updatedTag = new Tag - { - Id = existingTag.Id, - Name = "!@#$%^&*().-" - }; - - var serializer = GetSerializer(); - var content = serializer.Serialize(updatedTag); - - var request = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/tags/" + existingTag.StringId) - { - Content = new StringContent(content) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - var options = (JsonApiOptions)_factory.GetService(); - options.ValidateModelState = false; - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Create_Article_With_IsRequired_Name_Attribute_Succeeds() - { - // Arrange - string name = "Article Title"; - var context = _factory.GetService(); - var author = _authorFaker.Generate(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = "/api/v1/articles"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - var content = new - { - data = new - { - type = "articles", - attributes = new Dictionary - { - {"caption", 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 _factory.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var articleResponse = GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); - - var persistedArticle = await _dbContext.Articles - .SingleAsync(a => a.Id == articleResponse.Id); - - Assert.Equal(name, persistedArticle.Caption); - } - - [Fact] - public async Task Create_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() - { - // Arrange - string name = string.Empty; - var context = _factory.GetService(); - var author = _authorFaker.Generate(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = "/api/v1/articles"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - var content = new - { - data = new - { - type = "articles", - attributes = new Dictionary - { - {"caption", 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 _factory.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var articleResponse = GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); - - var persistedArticle = await _dbContext.Articles - .SingleAsync(a => a.Id == articleResponse.Id); - - Assert.Equal(name, persistedArticle.Caption); - } - - [Fact] - public async Task Create_Article_With_IsRequired_Name_Attribute_Explicitly_Null_Fails() - { - // Arrange - var context = _factory.GetService(); - var author = _authorFaker.Generate(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = "/api/v1/articles"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - var content = new - { - data = new - { - type = "articles", - attributes = new Dictionary - { - {"caption", 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 _factory.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 Caption field is required.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Create_Article_With_IsRequired_Name_Attribute_Missing_Fails() - { - // Arrange - var context = _factory.GetService(); - var author = _authorFaker.Generate(); - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var route = "/api/v1/articles"; - var request = new HttpRequestMessage(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 _factory.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 Caption field is required.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Update_Article_With_IsRequired_Name_Attribute_Succeeds() - { - // Arrange - var name = "Article Name"; - var context = _factory.GetService(); - var article = _articleFaker.Generate(); - context.Articles.Add(article); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - var content = new - { - data = new - { - type = "articles", - id = article.StringId, - attributes = new Dictionary - { - {"caption", name} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var persistedArticle = await _dbContext.Articles - .SingleOrDefaultAsync(a => a.Id == article.Id); - - var updatedName = persistedArticle.Caption; - Assert.Equal(name, updatedName); - } - - [Fact] - public async Task Update_Article_With_IsRequired_Name_Attribute_Missing_Succeeds() - { - // Arrange - var context = _factory.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(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 _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Update_Article_With_IsRequired_Name_Attribute_Explicitly_Null_Fails() - { - // Arrange - var context = _factory.GetService(); - var article = _articleFaker.Generate(); - context.Articles.Add(article); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - var content = new - { - data = new - { - type = "articles", - id = article.StringId, - attributes = new Dictionary - { - {"caption", null} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _factory.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 Caption field is required.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Update_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() - { - // Arrange - var context = _factory.GetService(); - var article = _articleFaker.Generate(); - context.Articles.Add(article); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - var content = new - { - data = new - { - type = "articles", - id = article.StringId, - attributes = new Dictionary - { - {"caption", ""} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var persistedArticle = await _dbContext.Articles - .SingleOrDefaultAsync(a => a.Id == article.Id); - - var updatedName = persistedArticle.Caption; - Assert.Equal("", updatedName); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs new file mode 100644 index 0000000000..4558ab222f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class Enterprise : Identifiable + { + [Attr] + [IsRequired] + [RegularExpression(@"^[\w\s]+$")] + public string CompanyName { get; set; } + + [Attr] + [MinLength(5)] + public string Industry { get; set; } + + [HasOne] + public PostalAddress MailAddress { get; set; } + + [HasMany] + public ICollection Partners { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs new file mode 100644 index 0000000000..fb4bd539f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class EnterprisePartner : Identifiable + { + [Attr] + [IsRequired] + [MinLength(3)] + public string Name { get; set; } + + [HasOne] + public PostalAddress PrimaryMailAddress { get; set; } + + [Attr] + [IsRequired] + public EnterprisePartnerClassification Classification { get; set; } + + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs new file mode 100644 index 0000000000..4d283cb760 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public enum EnterprisePartnerClassification + { + Silver, + Gold, + Platinum + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs new file mode 100644 index 0000000000..a542f103d7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class EnterprisePartnersController : JsonApiController + { + public EnterprisePartnersController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs new file mode 100644 index 0000000000..30d7039fea --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class EnterprisesController : JsonApiController + { + public EnterprisesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs new file mode 100644 index 0000000000..f5614791ad --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class ModelStateDbContext : DbContext + { + public DbSet Enterprises { get; set; } + public DbSet EnterprisePartners { get; set; } + public DbSet PostalAddresses { get; set; } + + public ModelStateDbContext(DbContextOptions options) : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs new file mode 100644 index 0000000000..71c15c3234 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class ModelStateValidationStartup : TestableStartup + where TDbContext : DbContext + { + public ModelStateValidationStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.ValidateModelState = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs new file mode 100644 index 0000000000..12bf67ca21 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -0,0 +1,316 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> + { + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + + public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task When_posting_resource_with_omitted_required_attribute_value_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["industry"] = "Transport" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + } + + [Fact] + public async Task When_posting_resource_with_null_for_required_attribute_value_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["companyName"] = null, + ["industry"] = "Transport" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + } + + [Fact] + public async Task When_posting_resource_with_invalid_attribute_value_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["companyName"] = "!@#$%^&*().-" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The field CompanyName must match the regular expression '^[\\w\\s]+$'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + } + + [Fact] + public async Task When_posting_resource_with_valid_attribute_value_it_must_succeed() + { + // Arrange + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["companyName"] = "Massive Dynamic" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["companyName"].Should().Be("Massive Dynamic"); + } + + [Fact] + public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Massive Dynamic", + Industry = "Manufacturing" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["industry"] = "Electronics" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task When_patching_resource_with_null_for_required_attribute_value_it_must_fail() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Massive Dynamic" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = null + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + } + + [Fact] + public async Task When_patching_resource_with_invalid_attribute_value_it_must_fail() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Massive Dynamic" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = "!@#$%^&*().-" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The field CompanyName must match the regular expression '^[\\w\\s]+$'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + } + + [Fact] + public async Task When_patching_resource_with_valid_attribute_value_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Massive Dynamic", + Industry = "Manufacturing" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = "Umbrella Corporation" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs new file mode 100644 index 0000000000..afaee9676a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> + { + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + + public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task When_posting_resource_with_invalid_attribute_value_it_must_succeed() + { + // Arrange + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["companyName"] = "!@#$%^&*().-" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["companyName"].Should().Be("!@#$%^&*().-"); + } + + [Fact] + public async Task When_patching_resource_with_invalid_attribute_value_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Massive Dynamic" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = "!@#$%^&*().-" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs new file mode 100644 index 0000000000..f04d8bb58d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class PostalAddress : Identifiable + { + [Attr] + [IsRequired] + public string StreetAddress { get; set; } + + [Attr] + public string AddressLine2 { get; set; } + + [Attr] + [IsRequired] + public string City { get; set; } + + [Attr] + [IsRequired] + public string Region { get; set; } + + [Attr] + [IsRequired] + public string ZipCode { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs new file mode 100644 index 0000000000..f56505ad24 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class PostalAddressesController : JsonApiController + { + public PostalAddressesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs index ba110a7399..e69ed360b8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests { - public sealed class TestableStartup : EmptyStartup + public class TestableStartup : EmptyStartup where TDbContext : DbContext { public TestableStartup(IConfiguration configuration) : base(configuration) @@ -19,12 +19,14 @@ public TestableStartup(IConfiguration configuration) : base(configuration) public override void ConfigureServices(IServiceCollection services) { - services.AddJsonApi(options => - { - options.IncludeExceptionStackTraceInErrors = true; - options.SerializerSettings.Formatting = Formatting.Indented; - options.SerializerSettings.Converters.Add(new StringEnumConverter()); - }); + services.AddJsonApi(SetJsonApiOptions); + } + + protected virtual void SetJsonApiOptions(JsonApiOptions options) + { + options.IncludeExceptionStackTraceInErrors = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.Converters.Add(new StringEnumConverter()); } public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) From bcd48b8727be400b74c6326c09fe5ec3733dd071 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 15 Sep 2020 17:51:38 +0200 Subject: [PATCH 2/8] Fixed: crash when post/patch contains relationships --- .../Errors/InvalidModelStateException.cs | 18 +- .../Middleware/HttpContextExtensions.cs | 16 +- .../Annotations/IsRequiredAttribute.cs | 48 ++- .../Serialization/RequestDeserializer.cs | 21 +- .../ModelStateValidation/Enterprise.cs | 3 + .../ModelStateValidationTests.cs | 404 ++++++++++++++++++ 6 files changed, 471 insertions(+), 39 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 038dee1166..d663a9bad8 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -34,10 +34,7 @@ private static IReadOnlyCollection FromModelState(ModelStateDictionary mo foreach (var (propertyName, entry) in modelState.Where(x => x.Value.Errors.Any())) { - PropertyInfo property = resourceType.GetProperty(propertyName); - - string attributeName = - property.GetCustomAttribute().PublicName ?? namingStrategy.GetPropertyName(property.Name, false); + string attributeName = GetDisplayNameForProperty(propertyName, resourceType, namingStrategy); foreach (var modelError in entry.Errors) { @@ -55,6 +52,19 @@ private static IReadOnlyCollection FromModelState(ModelStateDictionary mo return errors; } + private static string GetDisplayNameForProperty(string propertyName, Type resourceType, + NamingStrategy namingStrategy) + { + PropertyInfo property = resourceType.GetProperty(propertyName); + if (property != null) + { + var attrAttribute = property.GetCustomAttribute(); + return attrAttribute?.PublicName ?? namingStrategy.GetPropertyName(property.Name, false); + } + + return propertyName; + } + private static Error FromModelError(ModelError modelError, string attributeName, bool includeExceptionStackTraceInErrors) { diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index a2647de0d5..1e095409be 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -5,6 +5,9 @@ namespace JsonApiDotNetCore.Middleware { public static class HttpContextExtensions { + private const string _isJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest"; + private const string _disableRequiredValidatorKey = "JsonApiDotNetCore_DisableRequiredValidator"; + /// /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. /// @@ -12,7 +15,7 @@ public static bool IsJsonApiRequest(this HttpContext httpContext) { if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); - string value = httpContext.Items["IsJsonApiRequest"] as string; + string value = httpContext.Items[_isJsonApiRequestKey] as string; return value == bool.TrueString; } @@ -20,27 +23,26 @@ internal static void RegisterJsonApiRequest(this HttpContext httpContext) { if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); - httpContext.Items["IsJsonApiRequest"] = bool.TrueString; + httpContext.Items[_isJsonApiRequestKey] = bool.TrueString; } - internal static void DisableValidator(this HttpContext httpContext, string propertyName, string model) + internal static void DisableRequiredValidator(this HttpContext httpContext, string propertyName, string model) { if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); if (model == null) throw new ArgumentNullException(nameof(model)); - var itemKey = $"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}"; + var itemKey = _disableRequiredValidatorKey + $"_{model}_{propertyName}"; httpContext.Items[itemKey] = true; } - internal static bool IsValidatorDisabled(this HttpContext httpContext, string propertyName, string model) + internal static bool IsRequiredValidatorDisabled(this HttpContext httpContext, string propertyName, string model) { if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); if (model == null) throw new ArgumentNullException(nameof(model)); - return httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}") || - httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_Relation"); + return httpContext.Items.ContainsKey(_disableRequiredValidatorKey + $"_{model}_{propertyName}"); } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs index 71fbd8faa9..3ed8aff17b 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -11,22 +11,52 @@ namespace JsonApiDotNetCore.Resources.Annotations /// public sealed class IsRequiredAttribute : RequiredAttribute { - private bool _isDisabled; + public override bool RequiresValidationContext => true; /// - public override bool IsValid(object value) + protected override ValidationResult IsValid(object value, ValidationContext validationContext) { - return _isDisabled || base.IsValid(value); + if (validationContext == null) throw new ArgumentNullException(nameof(validationContext)); + + var request = validationContext.GetRequiredService(); + var httpContextAccessor = validationContext.GetRequiredService(); + + if (ShouldSkipValidationForResource(validationContext, request) || + ShouldSkipValidationForProperty(validationContext, httpContextAccessor.HttpContext)) + { + return ValidationResult.Success; + } + + return base.IsValid(value, validationContext); } - /// - protected override ValidationResult IsValid(object value, ValidationContext validationContext) + private static bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request) { - if (validationContext == null) throw new ArgumentNullException(nameof(validationContext)); + if (request.Kind == EndpointKind.Primary) + { + // If there is a relationship included in the data of the POST or PATCH, then the 'IsRequired' attribute will be disabled for any + // property within that object. For instance, a new article is posted and has a relationship included to an author. In this case, + // the author name (which has the 'IsRequired' attribute) will not be included in the POST. Unless disabled, the POST will fail. + + if (validationContext.ObjectType != request.PrimaryResource.ResourceType) + { + return true; + } + + if (validationContext.ObjectInstance is IIdentifiable identifiable && + identifiable.StringId != request.PrimaryId) + { + return true; + } + } - var httpContextAccessor = (IHttpContextAccessor)validationContext.GetRequiredService(typeof(IHttpContextAccessor)); - _isDisabled = httpContextAccessor.HttpContext.IsValidatorDisabled(validationContext.MemberName, validationContext.ObjectType.Name); - return _isDisabled ? ValidationResult.Success : base.IsValid(value, validationContext); + return false; + } + + private static bool ShouldSkipValidationForProperty(ValidationContext validationContext, HttpContext httpContext) + { + return httpContext.IsRequiredValidatorDisabled(validationContext.MemberName, + validationContext.ObjectType.Name); } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index e7b3734b2f..d2e3a8cee4 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -72,12 +72,11 @@ protected override IIdentifiable SetAttributes(IIdentifiable resource, IDictiona { if (attr.Property.GetCustomAttribute() != null) { - bool disableValidator = attributeValues == null || attributeValues.Count == 0 || - !attributeValues.TryGetValue(attr.PublicName, out _); + bool disableValidator = attributeValues == null || !attributeValues.ContainsKey(attr.PublicName); if (disableValidator) { - _httpContextAccessor.HttpContext.DisableValidator(attr.Property.Name, resource.GetType().Name); + _httpContextAccessor.HttpContext.DisableRequiredValidator(attr.Property.Name, resource.GetType().Name); } } } @@ -85,21 +84,5 @@ protected override IIdentifiable SetAttributes(IIdentifiable resource, IDictiona return base.SetAttributes(resource, attributeValues, attributes); } - - protected override IIdentifiable SetRelationships(IIdentifiable resource, IDictionary relationshipsValues, IReadOnlyCollection relationshipAttributes) - { - if (resource == null) throw new ArgumentNullException(nameof(resource)); - if (relationshipAttributes == null) throw new ArgumentNullException(nameof(relationshipAttributes)); - - // If there is a relationship included in the data of the POST or PATCH, then the 'IsRequired' attribute will be disabled for any - // property within that object. For instance, a new article is posted and has a relationship included to an author. In this case, - // the author name (which has the 'IsRequired' attribute) will not be included in the POST. Unless disabled, the POST will fail. - foreach (RelationshipAttribute attr in relationshipAttributes) - { - _httpContextAccessor.HttpContext.DisableValidator("Relation", attr.Property.Name); - } - - return base.SetRelationships(resource, relationshipsValues, relationshipAttributes); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs index 4558ab222f..b1c0249c72 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs @@ -21,5 +21,8 @@ public sealed class Enterprise : Identifiable [HasMany] public ICollection Partners { get; set; } + + [HasOne] + public Enterprise Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 12bf67ca21..8b395167f8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -143,6 +143,141 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe responseDocument.SingleData.Attributes["companyName"].Should().Be("Massive Dynamic"); } + [Fact] + public async Task When_posting_resource_with_multiple_violations_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "postalAddresses", + attributes = new Dictionary + { + ["addressLine2"] = "X" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/postalAddresses"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(4); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The City field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/city"); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[1].Title.Should().Be("Input validation failed."); + responseDocument.Errors[1].Detail.Should().Be("The Region field is required."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/data/attributes/region"); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[2].Title.Should().Be("Input validation failed."); + responseDocument.Errors[2].Detail.Should().Be("The ZipCode field is required."); + responseDocument.Errors[2].Source.Pointer.Should().Be("/data/attributes/zipCode"); + + responseDocument.Errors[3].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[3].Title.Should().Be("Input validation failed."); + responseDocument.Errors[3].Detail.Should().Be("The StreetAddress field is required."); + responseDocument.Errors[3].Source.Pointer.Should().Be("/data/attributes/streetAddress"); + } + + [Fact] + public async Task When_posting_resource_with_annotated_relationships_it_must_succeed() + { + // Arrange + var mailAddress = new PostalAddress + { + StreetAddress = "3555 S Las Vegas Blvd", + City = "Las Vegas", + Region = "Nevada", + ZipCode = "89109" + }; + + var partner = new EnterprisePartner + { + Name = "Flamingo Casino", + Classification = EnterprisePartnerClassification.Platinum, + PrimaryMailAddress = mailAddress + }; + + var parent = new Enterprise + { + CompanyName = "Caesars Entertainment" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.EnterprisePartners.Add(partner); + dbContext.Enterprises.Add(parent); + + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + attributes = new Dictionary + { + ["companyName"] = "Flamingo Hotel" + }, + relationships = new Dictionary + { + ["mailAddress"] = new + { + data = new + { + type = "postalAddresses", + id = mailAddress.StringId + } + }, + ["partners"] = new + { + data = new[] + { + new + { + type = "partners", + id = partner.StringId + } + } + }, + ["parent"] = new + { + data = new + { + type = "enterprises", + id = parent.StringId + } + } + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["companyName"].Should().Be("Flamingo Hotel"); + } + [Fact] public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() { @@ -312,5 +447,274 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.Should().BeNull(); } + + [Fact] + public async Task When_patching_resource_with_annotated_relationships_it_must_succeed() + { + // Arrange + var mailAddress = new PostalAddress + { + StreetAddress = "Massachusetts Hall", + City = "Cambridge", + Region = "Massachusetts", + ZipCode = "02138" + }; + + var enterprise = new Enterprise + { + CompanyName = "Bell Medics", + MailAddress = mailAddress, + Partners = new List + { + new EnterprisePartner + { + Name = "Harvard Laboratory", + Classification = EnterprisePartnerClassification.Silver, + PrimaryMailAddress = mailAddress + } + }, + Parent = new Enterprise + { + CompanyName = "Global Inc" + } + }; + + var otherMailAddress = new PostalAddress + { + StreetAddress = "2381 Burke Street", + City = "Cambridge", + Region = "Massachusetts", + ZipCode = "02141" + }; + + var otherPartner = new EnterprisePartner + { + Name = "FBI", + Classification = EnterprisePartnerClassification.Gold + }; + + var otherParent = new Enterprise + { + CompanyName = "World Inc" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.AddRange(enterprise, otherParent); + dbContext.PostalAddresses.Add(otherMailAddress); + dbContext.EnterprisePartners.Add(otherPartner); + + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = "Massive Dynamic" + }, + relationships = new Dictionary + { + ["mailAddress"] = new + { + data = new + { + type = "postalAddresses", + id = otherMailAddress.StringId + } + }, + ["partners"] = new + { + data = new[] + { + new + { + type = "partners", + id = otherPartner.StringId + } + } + }, + ["parent"] = new + { + data = new + { + type = "enterprises", + id = otherParent.StringId + } + } + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact(Skip = "TODO: There seems no way from inside validator attribute to know where in the object graph we are.")] + public async Task When_patching_resource_with_self_reference_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Bell Medics", + Parent = new Enterprise + { + CompanyName = "Global Inc" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.AddRange(enterprise); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId, + attributes = new Dictionary + { + ["companyName"] = "Massive Dynamic" + }, + relationships = new Dictionary + { + ["parent"] = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId + } + } + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Bell Medics", + Parent = new Enterprise + { + CompanyName = "Global Inc" + } + }; + + var otherParent = new Enterprise + { + CompanyName = "World Inc" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.AddRange(enterprise, otherParent); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "enterprises", + id = otherParent.StringId + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId + "/relationships/parent"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task When_patching_annotated_ToMany_relationship_it_must_succeed() + { + // Arrange + var enterprise = new Enterprise + { + CompanyName = "Bell Medics", + Partners = new List + { + new EnterprisePartner + { + Name = "Harvard Laboratory", + Classification = EnterprisePartnerClassification.Silver, + } + } + }; + + var otherPartner = new EnterprisePartner + { + Name = "FBI", + Classification = EnterprisePartnerClassification.Gold + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Enterprises.Add(enterprise); + dbContext.EnterprisePartners.Add(otherPartner); + + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new[] + { + new + { + type = "enterprisePartners", + id = otherPartner.StringId + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/enterprises/" + enterprise.StringId + "/relationships/partners"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } } } From 187bf1448d80a6a49db7b54b6280814b393d1d2a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Sep 2020 10:43:18 +0200 Subject: [PATCH 3/8] Review feedback --- src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs | 4 ++-- .../Resources/Annotations/IsRequiredAttribute.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index 1e095409be..1e11c9758f 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -32,7 +32,7 @@ internal static void DisableRequiredValidator(this HttpContext httpContext, stri if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); if (model == null) throw new ArgumentNullException(nameof(model)); - var itemKey = _disableRequiredValidatorKey + $"_{model}_{propertyName}"; + var itemKey = $"{_disableRequiredValidatorKey}_{model}_{propertyName}"; httpContext.Items[itemKey] = true; } @@ -42,7 +42,7 @@ internal static bool IsRequiredValidatorDisabled(this HttpContext httpContext, s if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); if (model == null) throw new ArgumentNullException(nameof(model)); - return httpContext.Items.ContainsKey(_disableRequiredValidatorKey + $"_{model}_{propertyName}"); + return httpContext.Items.ContainsKey($"{_disableRequiredValidatorKey}_{model}_{propertyName}"); } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs index 3ed8aff17b..55824cf585 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -30,7 +30,7 @@ protected override ValidationResult IsValid(object value, ValidationContext vali return base.IsValid(value, validationContext); } - private static bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request) + private bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request) { if (request.Kind == EndpointKind.Primary) { @@ -53,7 +53,7 @@ private static bool ShouldSkipValidationForResource(ValidationContext validation return false; } - private static bool ShouldSkipValidationForProperty(ValidationContext validationContext, HttpContext httpContext) + private bool ShouldSkipValidationForProperty(ValidationContext validationContext, HttpContext httpContext) { return httpContext.IsRequiredValidatorDisabled(validationContext.MemberName, validationContext.ObjectType.Name); From c7818a726ebe8a3ff3f96a81695c24480a7c2da3 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Sep 2020 11:16:39 +0200 Subject: [PATCH 4/8] Initial take of caching solution from Maurits --- .../Annotations/IsRequiredAttribute.cs | 47 +++++++++++++++++-- .../IdentifiableComparer.cs | 5 +- .../ModelStateValidationTests.cs | 2 +- 3 files changed, 47 insertions(+), 7 deletions(-) rename src/JsonApiDotNetCore/{Hooks/Internal => Resources}/IdentifiableComparer.cs (85%) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs index 55824cf585..8531b705d0 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +14,9 @@ namespace JsonApiDotNetCore.Resources.Annotations /// public sealed class IsRequiredAttribute : RequiredAttribute { + // TODO: We need put this cache in some context, as it is not thread-safe (parallel requests) and grows indefinitely. + private static readonly HashSet _selfReferencingEntitiesCache = new HashSet(IdentifiableComparer.Instance); + public override bool RequiresValidationContext => true; /// @@ -43,12 +49,47 @@ private bool ShouldSkipValidationForResource(ValidationContext validationContext return true; } - if (validationContext.ObjectInstance is IIdentifiable identifiable && - identifiable.StringId != request.PrimaryId) + if (validationContext.ObjectInstance is IIdentifiable identifiable) { - return true; + if (identifiable.StringId != request.PrimaryId) + { + return true; + } + + if (ResourceIsSelfReferencing(validationContext, identifiable)) + { + _selfReferencingEntitiesCache.Add(identifiable); + + return false; + } + + if (_selfReferencingEntitiesCache.Contains(identifiable)) + { + return true; + } } } + + return false; + } + + private bool ResourceIsSelfReferencing(ValidationContext validationContext, IIdentifiable identifiable) + { + var provider = validationContext.GetRequiredService(); + var relationships = provider.GetResourceContext(validationContext.ObjectType).Relationships; + + foreach (var relationship in relationships) + { + if (relationship is HasOneAttribute hasOne) + { + var relationshipValue = (IIdentifiable) hasOne.GetValue(identifiable); + if (IdentifiableComparer.Instance.Equals(identifiable, relationshipValue)) + { + return true; + } + } + + } return false; } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs similarity index 85% rename from src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs rename to src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 4130afa92f..63c0d6dd46 100644 --- a/src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Hooks.Internal +namespace JsonApiDotNetCore.Resources { /// - /// Compares `IIdentifiable` with each other based on ID + /// Compares `IIdentifiable` instances with each other based on StringId. /// internal sealed class IdentifiableComparer : IEqualityComparer { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 8b395167f8..0262f5154a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -562,7 +562,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.Should().BeNull(); } - [Fact(Skip = "TODO: There seems no way from inside validator attribute to know where in the object graph we are.")] + [Fact] public async Task When_patching_resource_with_self_reference_it_must_succeed() { // Arrange From 31aced8e4b34b5509df4fb05dc8f807e3a764a90 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Sep 2020 17:05:37 +0200 Subject: [PATCH 5/8] Fixed caching for multiple self-references --- .../Annotations/IsRequiredAttribute.cs | 25 ++++---- .../ModelStateValidation/Enterprise.cs | 10 ++++ .../ModelStateValidation/EnterprisePartner.cs | 1 - .../ModelStateDbContext.cs | 11 ++++ .../ModelStateValidationTests.cs | 60 ++++++++++++++----- 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs index 8531b705d0..e4133e0934 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Http; @@ -14,8 +12,7 @@ namespace JsonApiDotNetCore.Resources.Annotations /// public sealed class IsRequiredAttribute : RequiredAttribute { - // TODO: We need put this cache in some context, as it is not thread-safe (parallel requests) and grows indefinitely. - private static readonly HashSet _selfReferencingEntitiesCache = new HashSet(IdentifiableComparer.Instance); + private const string _isSelfReferencingResourceKey = "JsonApiDotNetCore_IsSelfReferencingResource"; public override bool RequiresValidationContext => true; @@ -27,7 +24,7 @@ protected override ValidationResult IsValid(object value, ValidationContext vali var request = validationContext.GetRequiredService(); var httpContextAccessor = validationContext.GetRequiredService(); - if (ShouldSkipValidationForResource(validationContext, request) || + if (ShouldSkipValidationForResource(validationContext, request, httpContextAccessor.HttpContext) || ShouldSkipValidationForProperty(validationContext, httpContextAccessor.HttpContext)) { return ValidationResult.Success; @@ -36,7 +33,8 @@ protected override ValidationResult IsValid(object value, ValidationContext vali return base.IsValid(value, validationContext); } - private bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request) + private bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request, + HttpContext httpContext) { if (request.Kind == EndpointKind.Primary) { @@ -56,14 +54,18 @@ private bool ShouldSkipValidationForResource(ValidationContext validationContext return true; } - if (ResourceIsSelfReferencing(validationContext, identifiable)) + var isSelfReferencingResource = (bool?) httpContext.Items[_isSelfReferencingResourceKey]; + + if (isSelfReferencingResource == null) { - _selfReferencingEntitiesCache.Add(identifiable); + // When processing a request, the first time we get here is for the top-level resource. + // Subsequent validations for related resources inspect the cache to know that their validation can be skipped. - return false; + isSelfReferencingResource = IsSelfReferencingResource(identifiable, validationContext); + httpContext.Items[_isSelfReferencingResourceKey] = isSelfReferencingResource; } - if (_selfReferencingEntitiesCache.Contains(identifiable)) + if (isSelfReferencingResource.Value) { return true; } @@ -73,7 +75,7 @@ private bool ShouldSkipValidationForResource(ValidationContext validationContext return false; } - private bool ResourceIsSelfReferencing(ValidationContext validationContext, IIdentifiable identifiable) + private bool IsSelfReferencingResource(IIdentifiable identifiable, ValidationContext validationContext) { var provider = validationContext.GetRequiredService(); var relationships = provider.GetResourceContext(validationContext.ObjectType).Relationships; @@ -88,7 +90,6 @@ private bool ResourceIsSelfReferencing(ValidationContext validationContext, IIde return true; } } - } return false; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs index b1c0249c72..f2ad7b7702 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs @@ -12,6 +12,10 @@ public sealed class Enterprise : Identifiable [RegularExpression(@"^[\w\s]+$")] public string CompanyName { get; set; } + [Attr] + [IsRequired] + public string CityOfResidence { get; set; } + [Attr] [MinLength(5)] public string Industry { get; set; } @@ -24,5 +28,11 @@ public sealed class Enterprise : Identifiable [HasOne] public Enterprise Parent { get; set; } + + [HasOne] + public Enterprise Self { get; set; } + + [HasOne] + public Enterprise AlsoSelf { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs index fb4bd539f7..879ef1ddbd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs @@ -17,6 +17,5 @@ public sealed class EnterprisePartner : Identifiable [Attr] [IsRequired] public EnterprisePartnerClassification Classification { get; set; } - } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs index f5614791ad..47c104e4a5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs @@ -11,5 +11,16 @@ public sealed class ModelStateDbContext : DbContext public ModelStateDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(enterprise => enterprise.Self) + .WithOne(); + + modelBuilder.Entity() + .HasOne(enterprise => enterprise.AlsoSelf) + .WithOne(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 0262f5154a..0e460e433e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -28,7 +28,8 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it type = "enterprises", attributes = new Dictionary { - ["industry"] = "Transport" + ["industry"] = "Transport", + ["cityOfResidence"] = "Cambridge" } } }; @@ -61,7 +62,8 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i attributes = new Dictionary { ["companyName"] = null, - ["industry"] = "Transport" + ["industry"] = "Transport", + ["cityOfResidence"] = "Cambridge" } } }; @@ -93,7 +95,8 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai type = "enterprises", attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-" + ["companyName"] = "!@#$%^&*().-", + ["cityOfResidence"] = "Cambridge" } } }; @@ -125,7 +128,8 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe type = "enterprises", attributes = new Dictionary { - ["companyName"] = "Massive Dynamic" + ["companyName"] = "Massive Dynamic", + ["cityOfResidence"] = "Cambridge" } } }; @@ -141,6 +145,7 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes["companyName"].Should().Be("Massive Dynamic"); + responseDocument.SingleData.Attributes["cityOfResidence"].Should().Be("Cambridge"); } [Fact] @@ -212,7 +217,8 @@ public async Task When_posting_resource_with_annotated_relationships_it_must_suc var parent = new Enterprise { - CompanyName = "Caesars Entertainment" + CompanyName = "Caesars Entertainment", + CityOfResidence = "Las Vegas" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -230,7 +236,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "enterprises", attributes = new Dictionary { - ["companyName"] = "Flamingo Hotel" + ["companyName"] = "Flamingo Hotel", + ["cityOfResidence"] = "Las Vegas" }, relationships = new Dictionary { @@ -285,6 +292,7 @@ public async Task When_patching_resource_with_omitted_required_attribute_value_i var enterprise = new Enterprise { CompanyName = "Massive Dynamic", + CityOfResidence = "Cambridge", Industry = "Manufacturing" }; @@ -325,7 +333,8 @@ public async Task When_patching_resource_with_null_for_required_attribute_value_ // Arrange var enterprise = new Enterprise { - CompanyName = "Massive Dynamic" + CompanyName = "Massive Dynamic", + CityOfResidence = "Cambridge" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -369,7 +378,8 @@ public async Task When_patching_resource_with_invalid_attribute_value_it_must_fa // Arrange var enterprise = new Enterprise { - CompanyName = "Massive Dynamic" + CompanyName = "Massive Dynamic", + CityOfResidence = "Cambridge" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -414,6 +424,7 @@ public async Task When_patching_resource_with_valid_attribute_value_it_must_succ var enterprise = new Enterprise { CompanyName = "Massive Dynamic", + CityOfResidence = "Cambridge", Industry = "Manufacturing" }; @@ -463,6 +474,7 @@ public async Task When_patching_resource_with_annotated_relationships_it_must_su var enterprise = new Enterprise { CompanyName = "Bell Medics", + CityOfResidence = "Cambridge", MailAddress = mailAddress, Partners = new List { @@ -475,7 +487,8 @@ public async Task When_patching_resource_with_annotated_relationships_it_must_su }, Parent = new Enterprise { - CompanyName = "Global Inc" + CompanyName = "Global Inc", + CityOfResidence = "New York" } }; @@ -495,7 +508,8 @@ public async Task When_patching_resource_with_annotated_relationships_it_must_su var otherParent = new Enterprise { - CompanyName = "World Inc" + CompanyName = "World Inc", + CityOfResidence = "New York" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -563,21 +577,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_self_reference_it_must_succeed() + public async Task When_patching_resource_with_multiple_self_references_it_must_succeed() { // Arrange var enterprise = new Enterprise { CompanyName = "Bell Medics", + CityOfResidence = "Cambridge", Parent = new Enterprise { - CompanyName = "Global Inc" + CompanyName = "Global Inc", + CityOfResidence = "New York" } }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.AddRange(enterprise); + dbContext.Enterprises.Add(enterprise); await dbContext.SaveChangesAsync(); }); @@ -593,7 +609,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, relationships = new Dictionary { - ["parent"] = new + ["self"] = new + { + data = new + { + type = "enterprises", + id = enterprise.StringId + } + }, + ["alsoSelf"] = new { data = new { @@ -624,15 +648,18 @@ public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() var enterprise = new Enterprise { CompanyName = "Bell Medics", + CityOfResidence = "Cambridge", Parent = new Enterprise { - CompanyName = "Global Inc" + CompanyName = "Global Inc", + CityOfResidence = "New York" } }; var otherParent = new Enterprise { - CompanyName = "World Inc" + CompanyName = "World Inc", + CityOfResidence = "New York" }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -669,6 +696,7 @@ public async Task When_patching_annotated_ToMany_relationship_it_must_succeed() var enterprise = new Enterprise { CompanyName = "Bell Medics", + CityOfResidence = "Cambridge", Partners = new List { new EnterprisePartner From a49b5a0a2e605fce393502fa0bdb749b09c5a978 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Sep 2020 17:57:11 +0200 Subject: [PATCH 6/8] Fixed broken tests --- .../ModelStateValidation/NoModelStateValidationTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index afaee9676a..225d8c2479 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -28,7 +28,8 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_suc type = "enterprises", attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-" + ["companyName"] = "!@#$%^&*().-", + ["cityOfResidence"] = "Cambridge" } } }; @@ -52,7 +53,8 @@ public async Task When_patching_resource_with_invalid_attribute_value_it_must_su // Arrange var enterprise = new Enterprise { - CompanyName = "Massive Dynamic" + CompanyName = "Massive Dynamic", + CityOfResidence = "Cambridge" }; await _testContext.RunOnDatabaseAsync(async dbContext => From 8789e861b5855ef1a214d5a319e1ee495ab1b920 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Sep 2020 20:17:18 +0200 Subject: [PATCH 7/8] Rewrite of tests with alternative models to enable better hierarchy checks --- .../ModelStateValidation/Enterprise.cs | 38 -- .../EnterprisePartnerClassification.cs | 9 - .../EnterprisePartnersController.cs | 16 - .../ModelStateDbContext.cs | 19 +- .../ModelStateValidationTests.cs | 377 +++++++++--------- .../NoModelStateValidationTests.cs | 26 +- .../ModelStateValidation/PostalAddress.cs | 27 -- ...ller.cs => SystemDirectoriesController.cs} | 6 +- .../ModelStateValidation/SystemDirectory.cs | 38 ++ .../{EnterprisePartner.cs => SystemFile.cs} | 12 +- ...Controller.cs => SystemFilesController.cs} | 6 +- 11 files changed, 254 insertions(+), 320 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/{PostalAddressesController.cs => SystemDirectoriesController.cs} (56%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/{EnterprisePartner.cs => SystemFile.cs} (51%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/{EnterprisesController.cs => SystemFilesController.cs} (63%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs deleted file mode 100644 index f2ad7b7702..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/Enterprise.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation -{ - public sealed class Enterprise : Identifiable - { - [Attr] - [IsRequired] - [RegularExpression(@"^[\w\s]+$")] - public string CompanyName { get; set; } - - [Attr] - [IsRequired] - public string CityOfResidence { get; set; } - - [Attr] - [MinLength(5)] - public string Industry { get; set; } - - [HasOne] - public PostalAddress MailAddress { get; set; } - - [HasMany] - public ICollection Partners { get; set; } - - [HasOne] - public Enterprise Parent { get; set; } - - [HasOne] - public Enterprise Self { get; set; } - - [HasOne] - public Enterprise AlsoSelf { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs deleted file mode 100644 index 4d283cb760..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnerClassification.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation -{ - public enum EnterprisePartnerClassification - { - Silver, - Gold, - Platinum - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs deleted file mode 100644 index a542f103d7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartnersController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation -{ - public sealed class EnterprisePartnersController : JsonApiController - { - public EnterprisePartnersController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs index 47c104e4a5..6add39c7b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs @@ -4,22 +4,25 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { public sealed class ModelStateDbContext : DbContext { - public DbSet Enterprises { get; set; } - public DbSet EnterprisePartners { get; set; } - public DbSet PostalAddresses { get; set; } + public DbSet Directories { get; set; } + public DbSet Files { get; set; } public ModelStateDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity() - .HasOne(enterprise => enterprise.Self) + builder.Entity() + .HasMany(systemDirectory => systemDirectory.Subdirectories) + .WithOne(x => x.Parent); + + builder.Entity() + .HasOne(systemDirectory => systemDirectory.Self) .WithOne(); - modelBuilder.Entity() - .HasOne(enterprise => enterprise.AlsoSelf) + builder.Entity() + .HasOne(systemDirectory => systemDirectory.AlsoSelf) .WithOne(); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 0e460e433e..e2f5397dbd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -25,17 +25,16 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["industry"] = "Transport", - ["cityOfResidence"] = "Cambridge" + ["isCaseSensitive"] = "true" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -46,8 +45,8 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + responseDocument.Errors[0].Detail.Should().Be("The Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); } [Fact] @@ -58,18 +57,17 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["companyName"] = null, - ["industry"] = "Transport", - ["cityOfResidence"] = "Cambridge" + ["name"] = null, + ["isCaseSensitive"] = "true" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -80,8 +78,8 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + responseDocument.Errors[0].Detail.Should().Be("The Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); } [Fact] @@ -92,17 +90,17 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-", - ["cityOfResidence"] = "Cambridge" + ["name"] = "!@#$%^&*().-", + ["isCaseSensitive"] = "true" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -113,8 +111,8 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The field CompanyName must match the regular expression '^[\\w\\s]+$'."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + responseDocument.Errors[0].Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); } [Fact] @@ -125,17 +123,17 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["companyName"] = "Massive Dynamic", - ["cityOfResidence"] = "Cambridge" + ["name"] = "Projects", + ["isCaseSensitive"] = "true" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -144,8 +142,8 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["companyName"].Should().Be("Massive Dynamic"); - responseDocument.SingleData.Attributes["cityOfResidence"].Should().Be("Cambridge"); + responseDocument.SingleData.Attributes["name"].Should().Be("Projects"); + responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); } [Fact] @@ -156,16 +154,16 @@ public async Task When_posting_resource_with_multiple_violations_it_must_fail() { data = new { - type = "postalAddresses", + type = "systemDirectories", attributes = new Dictionary { - ["addressLine2"] = "X" + ["sizeInBytes"] = "-1" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/postalAddresses"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -173,58 +171,50 @@ public async Task When_posting_resource_with_multiple_violations_it_must_fail() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(4); + responseDocument.Errors.Should().HaveCount(3); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The City field is required."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/city"); + responseDocument.Errors[0].Detail.Should().Be("The Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[1].Title.Should().Be("Input validation failed."); - responseDocument.Errors[1].Detail.Should().Be("The Region field is required."); - responseDocument.Errors[1].Source.Pointer.Should().Be("/data/attributes/region"); + responseDocument.Errors[1].Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); + responseDocument.Errors[1].Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[2].Title.Should().Be("Input validation failed."); - responseDocument.Errors[2].Detail.Should().Be("The ZipCode field is required."); - responseDocument.Errors[2].Source.Pointer.Should().Be("/data/attributes/zipCode"); - - responseDocument.Errors[3].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors[3].Title.Should().Be("Input validation failed."); - responseDocument.Errors[3].Detail.Should().Be("The StreetAddress field is required."); - responseDocument.Errors[3].Source.Pointer.Should().Be("/data/attributes/streetAddress"); + responseDocument.Errors[2].Detail.Should().Be("The IsCaseSensitive field is required."); + responseDocument.Errors[2].Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } [Fact] public async Task When_posting_resource_with_annotated_relationships_it_must_succeed() { // Arrange - var mailAddress = new PostalAddress + var parentDirectory = new SystemDirectory { - StreetAddress = "3555 S Las Vegas Blvd", - City = "Las Vegas", - Region = "Nevada", - ZipCode = "89109" + Name = "Shared", + IsCaseSensitive = true }; - var partner = new EnterprisePartner + var subdirectory = new SystemDirectory { - Name = "Flamingo Casino", - Classification = EnterprisePartnerClassification.Platinum, - PrimaryMailAddress = mailAddress + Name = "Open Source", + IsCaseSensitive = true }; - var parent = new Enterprise + var file = new SystemFile { - CompanyName = "Caesars Entertainment", - CityOfResidence = "Las Vegas" + FileName = "Main.cs", + SizeInBytes = 100 }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.EnterprisePartners.Add(partner); - dbContext.Enterprises.Add(parent); + dbContext.Directories.AddRange(parentDirectory, subdirectory); + dbContext.Files.Add(file); await dbContext.SaveChangesAsync(); }); @@ -233,30 +223,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["companyName"] = "Flamingo Hotel", - ["cityOfResidence"] = "Las Vegas" + ["name"] = "Projects", + ["isCaseSensitive"] = "true" }, relationships = new Dictionary { - ["mailAddress"] = new + ["subdirectories"] = new { - data = new + data = new[] { - type = "postalAddresses", - id = mailAddress.StringId + new + { + type = "systemDirectories", + id = subdirectory.StringId + } } }, - ["partners"] = new + ["files"] = new { data = new[] { new { - type = "partners", - id = partner.StringId + type = "systemFiles", + id = file.StringId } } }, @@ -264,8 +257,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = parent.StringId + type = "systemDirectories", + id = parentDirectory.StringId } } } @@ -273,7 +266,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -282,23 +275,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["companyName"].Should().Be("Flamingo Hotel"); + responseDocument.SingleData.Attributes["name"].Should().Be("Projects"); + responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); } [Fact] public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Massive Dynamic", - CityOfResidence = "Cambridge", - Industry = "Manufacturing" + Name = "Projects", + IsCaseSensitive = true }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -306,17 +299,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["industry"] = "Electronics" + ["sizeInBytes"] = "100" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -331,15 +324,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_patching_resource_with_null_for_required_attribute_value_it_must_fail() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Massive Dynamic", - CityOfResidence = "Cambridge" + Name = "Projects", + IsCaseSensitive = true }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -347,17 +340,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = null + ["name"] = null } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -368,23 +361,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The CompanyName field is required."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + responseDocument.Errors[0].Detail.Should().Be("The Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); } [Fact] public async Task When_patching_resource_with_invalid_attribute_value_it_must_fail() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Massive Dynamic", - CityOfResidence = "Cambridge" + Name = "Projects", + IsCaseSensitive = true }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -392,17 +385,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-" + ["name"] = "!@#$%^&*().-" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -413,24 +406,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); responseDocument.Errors[0].Title.Should().Be("Input validation failed."); - responseDocument.Errors[0].Detail.Should().Be("The field CompanyName must match the regular expression '^[\\w\\s]+$'."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/companyName"); + responseDocument.Errors[0].Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); } [Fact] public async Task When_patching_resource_with_valid_attribute_value_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Massive Dynamic", - CityOfResidence = "Cambridge", - Industry = "Manufacturing" + Name = "Projects", + IsCaseSensitive = true }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -438,17 +430,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = "Umbrella Corporation" + ["name"] = "Repositories" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -463,60 +455,53 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_patching_resource_with_annotated_relationships_it_must_succeed() { // Arrange - var mailAddress = new PostalAddress + var directory = new SystemDirectory { - StreetAddress = "Massachusetts Hall", - City = "Cambridge", - Region = "Massachusetts", - ZipCode = "02138" - }; - - var enterprise = new Enterprise - { - CompanyName = "Bell Medics", - CityOfResidence = "Cambridge", - MailAddress = mailAddress, - Partners = new List + Name = "Projects", + IsCaseSensitive = false, + Subdirectories = new List + { + new SystemDirectory + { + Name = "C#", + IsCaseSensitive = false + } + }, + Files = new List { - new EnterprisePartner + new SystemFile { - Name = "Harvard Laboratory", - Classification = EnterprisePartnerClassification.Silver, - PrimaryMailAddress = mailAddress + FileName = "readme.txt" } }, - Parent = new Enterprise + Parent = new SystemDirectory { - CompanyName = "Global Inc", - CityOfResidence = "New York" + Name = "Data", + IsCaseSensitive = false } }; - var otherMailAddress = new PostalAddress + var otherParent = new SystemDirectory { - StreetAddress = "2381 Burke Street", - City = "Cambridge", - Region = "Massachusetts", - ZipCode = "02141" + Name = "Shared", + IsCaseSensitive = false }; - var otherPartner = new EnterprisePartner + var otherSubdirectory = new SystemDirectory { - Name = "FBI", - Classification = EnterprisePartnerClassification.Gold + Name = "Shared", + IsCaseSensitive = false }; - var otherParent = new Enterprise + var otherFile = new SystemFile { - CompanyName = "World Inc", - CityOfResidence = "New York" + FileName = "readme.md" }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.AddRange(enterprise, otherParent); - dbContext.PostalAddresses.Add(otherMailAddress); - dbContext.EnterprisePartners.Add(otherPartner); + dbContext.Directories.AddRange(directory, otherParent, otherSubdirectory); + dbContext.Files.Add(otherFile); await dbContext.SaveChangesAsync(); }); @@ -525,30 +510,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = "Massive Dynamic" + ["name"] = "Project Files" }, relationships = new Dictionary { - ["mailAddress"] = new + ["subdirectories"] = new { - data = new + data = new[] { - type = "postalAddresses", - id = otherMailAddress.StringId + new + { + type = "systemDirectories", + id = otherSubdirectory.StringId + } } }, - ["partners"] = new + ["files"] = new { data = new[] { new { - type = "partners", - id = otherPartner.StringId + type = "systemFiles", + id = otherFile.StringId } } }, @@ -556,7 +544,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", + type = "systemDirectories", id = otherParent.StringId } } @@ -565,7 +553,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -580,20 +568,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_patching_resource_with_multiple_self_references_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Bell Medics", - CityOfResidence = "Cambridge", - Parent = new Enterprise - { - CompanyName = "Global Inc", - CityOfResidence = "New York" - } + Name = "Projects", + IsCaseSensitive = false }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -601,11 +584,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = "Massive Dynamic" + ["name"] = "Project files" }, relationships = new Dictionary { @@ -613,16 +596,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId + type = "systemDirectories", + id = directory.StringId } }, ["alsoSelf"] = new { data = new { - type = "enterprises", - id = enterprise.StringId + type = "systemDirectories", + id = directory.StringId } } } @@ -630,7 +613,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -645,26 +628,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Bell Medics", - CityOfResidence = "Cambridge", - Parent = new Enterprise + Name = "Projects", + IsCaseSensitive = true, + Parent = new SystemDirectory { - CompanyName = "Global Inc", - CityOfResidence = "New York" + Name = "Data", + IsCaseSensitive = true } }; - var otherParent = new Enterprise + var otherParent = new SystemDirectory { - CompanyName = "World Inc", - CityOfResidence = "New York" + Name = "Data files", + IsCaseSensitive = true }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.AddRange(enterprise, otherParent); + dbContext.Directories.AddRange(directory, otherParent); await dbContext.SaveChangesAsync(); }); @@ -672,13 +655,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", + type = "systemDirectories", id = otherParent.StringId } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId + "/relationships/parent"; + string route = "/systemDirectories/" + directory.StringId + "/relationships/parent"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -693,30 +676,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_patching_annotated_ToMany_relationship_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Bell Medics", - CityOfResidence = "Cambridge", - Partners = new List + Name = "Projects", + IsCaseSensitive = true, + Files = new List { - new EnterprisePartner + new SystemFile + { + FileName = "Main.cs" + }, + new SystemFile { - Name = "Harvard Laboratory", - Classification = EnterprisePartnerClassification.Silver, + FileName = "Program.cs" } } }; - var otherPartner = new EnterprisePartner + var otherFile = new SystemFile { - Name = "FBI", - Classification = EnterprisePartnerClassification.Gold + FileName = "EntryPoint.cs" }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); - dbContext.EnterprisePartners.Add(otherPartner); + dbContext.Directories.Add(directory); + dbContext.Files.Add(otherFile); await dbContext.SaveChangesAsync(); }); @@ -727,14 +712,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { new { - type = "enterprisePartners", - id = otherPartner.StringId + type = "systemFiles", + id = otherFile.StringId } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId + "/relationships/partners"; + string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 225d8c2479..86a22b14dc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -25,17 +25,17 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_suc { data = new { - type = "enterprises", + type = "systemDirectories", attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-", - ["cityOfResidence"] = "Cambridge" + ["name"] = "!@#$%^&*().-", + ["isCaseSensitive"] = "false" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises"; + string route = "/systemDirectories"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -44,22 +44,22 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_suc httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["companyName"].Should().Be("!@#$%^&*().-"); + responseDocument.SingleData.Attributes["name"].Should().Be("!@#$%^&*().-"); } [Fact] public async Task When_patching_resource_with_invalid_attribute_value_it_must_succeed() { // Arrange - var enterprise = new Enterprise + var directory = new SystemDirectory { - CompanyName = "Massive Dynamic", - CityOfResidence = "Cambridge" + Name = "Projects", + IsCaseSensitive = false }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Enterprises.Add(enterprise); + dbContext.Directories.Add(directory); await dbContext.SaveChangesAsync(); }); @@ -67,17 +67,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "enterprises", - id = enterprise.StringId, + type = "systemDirectories", + id = directory.StringId, attributes = new Dictionary { - ["companyName"] = "!@#$%^&*().-" + ["name"] = "!@#$%^&*().-" } } }; string requestBody = JsonConvert.SerializeObject(content); - string route = "/enterprises/" + enterprise.StringId; + string route = "/systemDirectories/" + directory.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs deleted file mode 100644 index f04d8bb58d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddress.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation -{ - public sealed class PostalAddress : Identifiable - { - [Attr] - [IsRequired] - public string StreetAddress { get; set; } - - [Attr] - public string AddressLine2 { get; set; } - - [Attr] - [IsRequired] - public string City { get; set; } - - [Attr] - [IsRequired] - public string Region { get; set; } - - [Attr] - [IsRequired] - public string ZipCode { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs similarity index 56% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs index f56505ad24..5228903c5d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/PostalAddressesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class PostalAddressesController : JsonApiController + public sealed class SystemDirectoriesController : JsonApiController { - public PostalAddressesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + public SystemDirectoriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs new file mode 100644 index 0000000000..e60b4bd864 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class SystemDirectory : Identifiable + { + [Attr] + [IsRequired] + [RegularExpression(@"^[\w\s]+$")] + public string Name { get; set; } + + [Attr] + [IsRequired] + public bool? IsCaseSensitive { get; set; } + + [Attr] + [Range(typeof(long), "0", "9223372036854775807")] + public long SizeInBytes { get; set; } + + [HasMany] + public ICollection Subdirectories { get; set; } + + [HasMany] + public ICollection Files { get; set; } + + [HasOne] + public SystemDirectory Self { get; set; } + + [HasOne] + public SystemDirectory AlsoSelf { get; set; } + + [HasOne] + public SystemDirectory Parent { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs similarity index 51% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs index 879ef1ddbd..bb2e27a6fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisePartner.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs @@ -4,18 +4,16 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class EnterprisePartner : Identifiable + public sealed class SystemFile : Identifiable { [Attr] [IsRequired] - [MinLength(3)] - public string Name { get; set; } - - [HasOne] - public PostalAddress PrimaryMailAddress { get; set; } + [MinLength(1)] + public string FileName { get; set; } [Attr] [IsRequired] - public EnterprisePartnerClassification Classification { get; set; } + [Range(typeof(long), "0", "9223372036854775807")] + public long SizeInBytes { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs similarity index 63% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs index 30d7039fea..9278f59766 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/EnterprisesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class EnterprisesController : JsonApiController + public sealed class SystemFilesController : JsonApiController { - public EnterprisesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + public SystemFilesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } From f3c8571e9f26e07757812f49567e423be1a400f0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 18 Sep 2020 10:45:24 +0200 Subject: [PATCH 8/8] Added fix for many-to-many relationships --- .../Annotations/IsRequiredAttribute.cs | 14 +++++ .../ModelStateValidationTests.cs | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs index e4133e0934..8a2b0dc37b 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -1,5 +1,8 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Http; @@ -90,6 +93,17 @@ private bool IsSelfReferencingResource(IIdentifiable identifiable, ValidationCon return true; } } + + if (relationship is HasManyAttribute hasMany) + { + var collection = (IEnumerable) hasMany.GetValue(identifiable); + + if (collection != null && collection.OfType().Any(resource => + IdentifiableComparer.Instance.Equals(identifiable, resource))) + { + return true; + } + } } return false; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index e2f5397dbd..e85da62d5b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -624,6 +624,61 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.Should().BeNull(); } + [Fact] + public async Task When_patching_resource_with_collection_of_self_references_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = false + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(directory); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "systemDirectories", + id = directory.StringId, + attributes = new Dictionary + { + ["name"] = "Project files" + }, + relationships = new Dictionary + { + ["subdirectories"] = new + { + data = new[] + { + new + { + type = "systemDirectories", + id = directory.StringId + } + } + } + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories/" + directory.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + [Fact] public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() {