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/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..1e11c9758f 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..8a2b0dc37b 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -1,5 +1,9 @@ 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; using Microsoft.Extensions.DependencyInjection; @@ -11,22 +15,104 @@ namespace JsonApiDotNetCore.Resources.Annotations /// public sealed class IsRequiredAttribute : RequiredAttribute { - private bool _isDisabled; + private const string _isSelfReferencingResourceKey = "JsonApiDotNetCore_IsSelfReferencingResource"; - /// - public override bool IsValid(object value) - { - return _isDisabled || base.IsValid(value); - } + public override bool RequiresValidationContext => true; /// protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (validationContext == null) throw new ArgumentNullException(nameof(validationContext)); - var httpContextAccessor = (IHttpContextAccessor)validationContext.GetRequiredService(typeof(IHttpContextAccessor)); - _isDisabled = httpContextAccessor.HttpContext.IsValidatorDisabled(validationContext.MemberName, validationContext.ObjectType.Name); - return _isDisabled ? ValidationResult.Success : base.IsValid(value, validationContext); + var request = validationContext.GetRequiredService(); + var httpContextAccessor = validationContext.GetRequiredService(); + + if (ShouldSkipValidationForResource(validationContext, request, httpContextAccessor.HttpContext) || + ShouldSkipValidationForProperty(validationContext, httpContextAccessor.HttpContext)) + { + return ValidationResult.Success; + } + + return base.IsValid(value, validationContext); + } + + private bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request, + HttpContext httpContext) + { + 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) + { + if (identifiable.StringId != request.PrimaryId) + { + return true; + } + + var isSelfReferencingResource = (bool?) httpContext.Items[_isSelfReferencingResourceKey]; + + if (isSelfReferencingResource == null) + { + // 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. + + isSelfReferencingResource = IsSelfReferencingResource(identifiable, validationContext); + httpContext.Items[_isSelfReferencingResourceKey] = isSelfReferencingResource; + } + + if (isSelfReferencingResource.Value) + { + return true; + } + } + } + + return false; + } + + private bool IsSelfReferencingResource(IIdentifiable identifiable, ValidationContext validationContext) + { + 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; + } + } + + 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; + } + + private bool ShouldSkipValidationForProperty(ValidationContext validationContext, HttpContext httpContext) + { + return httpContext.IsRequiredValidatorDisabled(validationContext.MemberName, + validationContext.ObjectType.Name); } } } 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/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/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/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs new file mode 100644 index 0000000000..6add39c7b0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class ModelStateDbContext : DbContext + { + public DbSet Directories { get; set; } + public DbSet Files { get; set; } + + public ModelStateDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(systemDirectory => systemDirectory.Subdirectories) + .WithOne(x => x.Parent); + + builder.Entity() + .HasOne(systemDirectory => systemDirectory.Self) + .WithOne(); + + builder.Entity() + .HasOne(systemDirectory => systemDirectory.AlsoSelf) + .WithOne(); + } + } +} 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..e85da62d5b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -0,0 +1,788 @@ +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 = "systemDirectories", + attributes = new Dictionary + { + ["isCaseSensitive"] = "true" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // 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 Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); + } + + [Fact] + public async Task When_posting_resource_with_null_for_required_attribute_value_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "systemDirectories", + attributes = new Dictionary + { + ["name"] = null, + ["isCaseSensitive"] = "true" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // 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 Name field is required."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); + } + + [Fact] + public async Task When_posting_resource_with_invalid_attribute_value_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "systemDirectories", + attributes = new Dictionary + { + ["name"] = "!@#$%^&*().-", + ["isCaseSensitive"] = "true" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // 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 Name must match the regular expression '^[\\w\\s]+$'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/name"); + } + + [Fact] + public async Task When_posting_resource_with_valid_attribute_value_it_must_succeed() + { + // Arrange + var content = new + { + data = new + { + type = "systemDirectories", + attributes = new Dictionary + { + ["name"] = "Projects", + ["isCaseSensitive"] = "true" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be("Projects"); + responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); + } + + [Fact] + public async Task When_posting_resource_with_multiple_violations_it_must_fail() + { + // Arrange + var content = new + { + data = new + { + type = "systemDirectories", + attributes = new Dictionary + { + ["sizeInBytes"] = "-1" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + 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 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 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 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 parentDirectory = new SystemDirectory + { + Name = "Shared", + IsCaseSensitive = true + }; + + var subdirectory = new SystemDirectory + { + Name = "Open Source", + IsCaseSensitive = true + }; + + var file = new SystemFile + { + FileName = "Main.cs", + SizeInBytes = 100 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.AddRange(parentDirectory, subdirectory); + dbContext.Files.Add(file); + + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "systemDirectories", + attributes = new Dictionary + { + ["name"] = "Projects", + ["isCaseSensitive"] = "true" + }, + relationships = new Dictionary + { + ["subdirectories"] = new + { + data = new[] + { + new + { + type = "systemDirectories", + id = subdirectory.StringId + } + } + }, + ["files"] = new + { + data = new[] + { + new + { + type = "systemFiles", + id = file.StringId + } + } + }, + ["parent"] = new + { + data = new + { + type = "systemDirectories", + id = parentDirectory.StringId + } + } + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + 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 directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true + }; + + 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 + { + ["sizeInBytes"] = "100" + } + } + }; + + 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_resource_with_null_for_required_attribute_value_it_must_fail() + { + // Arrange + var directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true + }; + + 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"] = null + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories/" + directory.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 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 directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true + }; + + 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"] = "!@#$%^&*().-" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories/" + directory.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 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 directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true + }; + + 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"] = "Repositories" + } + } + }; + + 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_resource_with_annotated_relationships_it_must_succeed() + { + // Arrange + var directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = false, + Subdirectories = new List + { + new SystemDirectory + { + Name = "C#", + IsCaseSensitive = false + } + }, + Files = new List + { + new SystemFile + { + FileName = "readme.txt" + } + }, + Parent = new SystemDirectory + { + Name = "Data", + IsCaseSensitive = false + } + }; + + var otherParent = new SystemDirectory + { + Name = "Shared", + IsCaseSensitive = false + }; + + var otherSubdirectory = new SystemDirectory + { + Name = "Shared", + IsCaseSensitive = false + }; + + var otherFile = new SystemFile + { + FileName = "readme.md" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.AddRange(directory, otherParent, otherSubdirectory); + dbContext.Files.Add(otherFile); + + 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 = otherSubdirectory.StringId + } + } + }, + ["files"] = new + { + data = new[] + { + new + { + type = "systemFiles", + id = otherFile.StringId + } + } + }, + ["parent"] = new + { + data = new + { + type = "systemDirectories", + id = otherParent.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_resource_with_multiple_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 + { + ["self"] = new + { + data = new + { + type = "systemDirectories", + id = directory.StringId + } + }, + ["alsoSelf"] = new + { + data = 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_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() + { + // Arrange + var directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true, + Parent = new SystemDirectory + { + Name = "Data", + IsCaseSensitive = true + } + }; + + var otherParent = new SystemDirectory + { + Name = "Data files", + IsCaseSensitive = true + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.AddRange(directory, otherParent); + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new + { + type = "systemDirectories", + id = otherParent.StringId + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories/" + directory.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 directory = new SystemDirectory + { + Name = "Projects", + IsCaseSensitive = true, + Files = new List + { + new SystemFile + { + FileName = "Main.cs" + }, + new SystemFile + { + FileName = "Program.cs" + } + } + }; + + var otherFile = new SystemFile + { + FileName = "EntryPoint.cs" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(directory); + dbContext.Files.Add(otherFile); + + await dbContext.SaveChangesAsync(); + }); + + var content = new + { + data = new[] + { + new + { + type = "systemFiles", + id = otherFile.StringId + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; + + // 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..86a22b14dc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -0,0 +1,91 @@ +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 = "systemDirectories", + attributes = new Dictionary + { + ["name"] = "!@#$%^&*().-", + ["isCaseSensitive"] = "false" + } + } + }; + + string requestBody = JsonConvert.SerializeObject(content); + string route = "/systemDirectories"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be("!@#$%^&*().-"); + } + + [Fact] + public async Task When_patching_resource_with_invalid_attribute_value_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"] = "!@#$%^&*().-" + } + } + }; + + 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(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs new file mode 100644 index 0000000000..5228903c5d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.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 SystemDirectoriesController : JsonApiController + { + 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/SystemFile.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs new file mode 100644 index 0000000000..bb2e27a6fc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +{ + public sealed class SystemFile : Identifiable + { + [Attr] + [IsRequired] + [MinLength(1)] + public string FileName { get; set; } + + [Attr] + [IsRequired] + [Range(typeof(long), "0", "9223372036854775807")] + public long SizeInBytes { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs new file mode 100644 index 0000000000..9278f59766 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.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 SystemFilesController : JsonApiController + { + public SystemFilesController(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)