Skip to content

Commit ca717d0

Browse files
author
Bart Koelman
authored
Fixed: Do not run ModelState validation for relationships in POST/PATCH (#831)
Fixed: crash when post/patch contains relationships
1 parent d93f021 commit ca717d0

18 files changed

+1149
-612
lines changed

src/Examples/JsonApiDotNetCoreExample/Models/Article.cs

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ namespace JsonApiDotNetCoreExample.Models
88
public sealed class Article : Identifiable
99
{
1010
[Attr]
11-
[IsRequired(AllowEmptyStrings = true)]
1211
public string Caption { get; set; }
1312

1413
[Attr]

src/Examples/JsonApiDotNetCoreExample/Models/Author.cs

-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ public sealed class Author : Identifiable
1111
public string FirstName { get; set; }
1212

1313
[Attr]
14-
[IsRequired(AllowEmptyStrings = true)]
1514
public string LastName { get; set; }
1615

1716
[Attr]

src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs

-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.ComponentModel.DataAnnotations;
21
using JsonApiDotNetCore.Resources;
32
using JsonApiDotNetCore.Resources.Annotations;
43

@@ -7,7 +6,6 @@ namespace JsonApiDotNetCoreExample.Models
76
public class Tag : Identifiable
87
{
98
[Attr]
10-
[RegularExpression(@"^\W$")]
119
public string Name { get; set; }
1210

1311
[Attr]

src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs

+14-4
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@ private static IReadOnlyCollection<Error> FromModelState(ModelStateDictionary mo
3434

3535
foreach (var (propertyName, entry) in modelState.Where(x => x.Value.Errors.Any()))
3636
{
37-
PropertyInfo property = resourceType.GetProperty(propertyName);
38-
39-
string attributeName =
40-
property.GetCustomAttribute<AttrAttribute>().PublicName ?? namingStrategy.GetPropertyName(property.Name, false);
37+
string attributeName = GetDisplayNameForProperty(propertyName, resourceType, namingStrategy);
4138

4239
foreach (var modelError in entry.Errors)
4340
{
@@ -55,6 +52,19 @@ private static IReadOnlyCollection<Error> FromModelState(ModelStateDictionary mo
5552
return errors;
5653
}
5754

55+
private static string GetDisplayNameForProperty(string propertyName, Type resourceType,
56+
NamingStrategy namingStrategy)
57+
{
58+
PropertyInfo property = resourceType.GetProperty(propertyName);
59+
if (property != null)
60+
{
61+
var attrAttribute = property.GetCustomAttribute<AttrAttribute>();
62+
return attrAttribute?.PublicName ?? namingStrategy.GetPropertyName(property.Name, false);
63+
}
64+
65+
return propertyName;
66+
}
67+
5868
private static Error FromModelError(ModelError modelError, string attributeName,
5969
bool includeExceptionStackTraceInErrors)
6070
{

src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs

+9-7
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,44 @@ namespace JsonApiDotNetCore.Middleware
55
{
66
public static class HttpContextExtensions
77
{
8+
private const string _isJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest";
9+
private const string _disableRequiredValidatorKey = "JsonApiDotNetCore_DisableRequiredValidator";
10+
811
/// <summary>
912
/// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore.
1013
/// </summary>
1114
public static bool IsJsonApiRequest(this HttpContext httpContext)
1215
{
1316
if (httpContext == null) throw new ArgumentNullException(nameof(httpContext));
1417

15-
string value = httpContext.Items["IsJsonApiRequest"] as string;
18+
string value = httpContext.Items[_isJsonApiRequestKey] as string;
1619
return value == bool.TrueString;
1720
}
1821

1922
internal static void RegisterJsonApiRequest(this HttpContext httpContext)
2023
{
2124
if (httpContext == null) throw new ArgumentNullException(nameof(httpContext));
2225

23-
httpContext.Items["IsJsonApiRequest"] = bool.TrueString;
26+
httpContext.Items[_isJsonApiRequestKey] = bool.TrueString;
2427
}
2528

26-
internal static void DisableValidator(this HttpContext httpContext, string propertyName, string model)
29+
internal static void DisableRequiredValidator(this HttpContext httpContext, string propertyName, string model)
2730
{
2831
if (httpContext == null) throw new ArgumentNullException(nameof(httpContext));
2932
if (propertyName == null) throw new ArgumentNullException(nameof(propertyName));
3033
if (model == null) throw new ArgumentNullException(nameof(model));
3134

32-
var itemKey = $"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}";
35+
var itemKey = $"{_disableRequiredValidatorKey}_{model}_{propertyName}";
3336
httpContext.Items[itemKey] = true;
3437
}
3538

36-
internal static bool IsValidatorDisabled(this HttpContext httpContext, string propertyName, string model)
39+
internal static bool IsRequiredValidatorDisabled(this HttpContext httpContext, string propertyName, string model)
3740
{
3841
if (httpContext == null) throw new ArgumentNullException(nameof(httpContext));
3942
if (propertyName == null) throw new ArgumentNullException(nameof(propertyName));
4043
if (model == null) throw new ArgumentNullException(nameof(model));
4144

42-
return httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}") ||
43-
httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_Relation");
45+
return httpContext.Items.ContainsKey($"{_disableRequiredValidatorKey}_{model}_{propertyName}");
4446
}
4547
}
4648
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
24
using System.ComponentModel.DataAnnotations;
5+
using System.Linq;
6+
using JsonApiDotNetCore.Configuration;
37
using JsonApiDotNetCore.Middleware;
48
using Microsoft.AspNetCore.Http;
59
using Microsoft.Extensions.DependencyInjection;
@@ -11,22 +15,104 @@ namespace JsonApiDotNetCore.Resources.Annotations
1115
/// </summary>
1216
public sealed class IsRequiredAttribute : RequiredAttribute
1317
{
14-
private bool _isDisabled;
18+
private const string _isSelfReferencingResourceKey = "JsonApiDotNetCore_IsSelfReferencingResource";
1519

16-
/// <inheritdoc />
17-
public override bool IsValid(object value)
18-
{
19-
return _isDisabled || base.IsValid(value);
20-
}
20+
public override bool RequiresValidationContext => true;
2121

2222
/// <inheritdoc />
2323
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
2424
{
2525
if (validationContext == null) throw new ArgumentNullException(nameof(validationContext));
2626

27-
var httpContextAccessor = (IHttpContextAccessor)validationContext.GetRequiredService(typeof(IHttpContextAccessor));
28-
_isDisabled = httpContextAccessor.HttpContext.IsValidatorDisabled(validationContext.MemberName, validationContext.ObjectType.Name);
29-
return _isDisabled ? ValidationResult.Success : base.IsValid(value, validationContext);
27+
var request = validationContext.GetRequiredService<IJsonApiRequest>();
28+
var httpContextAccessor = validationContext.GetRequiredService<IHttpContextAccessor>();
29+
30+
if (ShouldSkipValidationForResource(validationContext, request, httpContextAccessor.HttpContext) ||
31+
ShouldSkipValidationForProperty(validationContext, httpContextAccessor.HttpContext))
32+
{
33+
return ValidationResult.Success;
34+
}
35+
36+
return base.IsValid(value, validationContext);
37+
}
38+
39+
private bool ShouldSkipValidationForResource(ValidationContext validationContext, IJsonApiRequest request,
40+
HttpContext httpContext)
41+
{
42+
if (request.Kind == EndpointKind.Primary)
43+
{
44+
// If there is a relationship included in the data of the POST or PATCH, then the 'IsRequired' attribute will be disabled for any
45+
// property within that object. For instance, a new article is posted and has a relationship included to an author. In this case,
46+
// the author name (which has the 'IsRequired' attribute) will not be included in the POST. Unless disabled, the POST will fail.
47+
48+
if (validationContext.ObjectType != request.PrimaryResource.ResourceType)
49+
{
50+
return true;
51+
}
52+
53+
if (validationContext.ObjectInstance is IIdentifiable identifiable)
54+
{
55+
if (identifiable.StringId != request.PrimaryId)
56+
{
57+
return true;
58+
}
59+
60+
var isSelfReferencingResource = (bool?) httpContext.Items[_isSelfReferencingResourceKey];
61+
62+
if (isSelfReferencingResource == null)
63+
{
64+
// When processing a request, the first time we get here is for the top-level resource.
65+
// Subsequent validations for related resources inspect the cache to know that their validation can be skipped.
66+
67+
isSelfReferencingResource = IsSelfReferencingResource(identifiable, validationContext);
68+
httpContext.Items[_isSelfReferencingResourceKey] = isSelfReferencingResource;
69+
}
70+
71+
if (isSelfReferencingResource.Value)
72+
{
73+
return true;
74+
}
75+
}
76+
}
77+
78+
return false;
79+
}
80+
81+
private bool IsSelfReferencingResource(IIdentifiable identifiable, ValidationContext validationContext)
82+
{
83+
var provider = validationContext.GetRequiredService<IResourceContextProvider>();
84+
var relationships = provider.GetResourceContext(validationContext.ObjectType).Relationships;
85+
86+
foreach (var relationship in relationships)
87+
{
88+
if (relationship is HasOneAttribute hasOne)
89+
{
90+
var relationshipValue = (IIdentifiable) hasOne.GetValue(identifiable);
91+
if (IdentifiableComparer.Instance.Equals(identifiable, relationshipValue))
92+
{
93+
return true;
94+
}
95+
}
96+
97+
if (relationship is HasManyAttribute hasMany)
98+
{
99+
var collection = (IEnumerable) hasMany.GetValue(identifiable);
100+
101+
if (collection != null && collection.OfType<IIdentifiable>().Any(resource =>
102+
IdentifiableComparer.Instance.Equals(identifiable, resource)))
103+
{
104+
return true;
105+
}
106+
}
107+
}
108+
109+
return false;
110+
}
111+
112+
private bool ShouldSkipValidationForProperty(ValidationContext validationContext, HttpContext httpContext)
113+
{
114+
return httpContext.IsRequiredValidatorDisabled(validationContext.MemberName,
115+
validationContext.ObjectType.Name);
30116
}
31117
}
32118
}

src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs renamed to src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
using System.Collections.Generic;
2-
using JsonApiDotNetCore.Resources;
32

4-
namespace JsonApiDotNetCore.Hooks.Internal
3+
namespace JsonApiDotNetCore.Resources
54
{
65
/// <summary>
7-
/// Compares `IIdentifiable` with each other based on ID
6+
/// Compares `IIdentifiable` instances with each other based on StringId.
87
/// </summary>
98
internal sealed class IdentifiableComparer : IEqualityComparer<IIdentifiable>
109
{

src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs

+2-19
Original file line numberDiff line numberDiff line change
@@ -79,34 +79,17 @@ protected override IIdentifiable SetAttributes(IIdentifiable resource, IDictiona
7979
{
8080
if (attr.Property.GetCustomAttribute<IsRequiredAttribute>() != null)
8181
{
82-
bool disableValidator = attributeValues == null || attributeValues.Count == 0 ||
83-
!attributeValues.TryGetValue(attr.PublicName, out _);
82+
bool disableValidator = attributeValues == null || !attributeValues.ContainsKey(attr.PublicName);
8483

8584
if (disableValidator)
8685
{
87-
_httpContextAccessor.HttpContext.DisableValidator(attr.Property.Name, resource.GetType().Name);
86+
_httpContextAccessor.HttpContext.DisableRequiredValidator(attr.Property.Name, resource.GetType().Name);
8887
}
8988
}
9089
}
9190
}
9291

9392
return base.SetAttributes(resource, attributeValues, attributes);
9493
}
95-
96-
protected override IIdentifiable SetRelationships(IIdentifiable resource, IDictionary<string, RelationshipEntry> relationshipsValues, IReadOnlyCollection<RelationshipAttribute> relationshipAttributes)
97-
{
98-
if (resource == null) throw new ArgumentNullException(nameof(resource));
99-
if (relationshipAttributes == null) throw new ArgumentNullException(nameof(relationshipAttributes));
100-
101-
// If there is a relationship included in the data of the POST or PATCH, then the 'IsRequired' attribute will be disabled for any
102-
// property within that object. For instance, a new article is posted and has a relationship included to an author. In this case,
103-
// the author name (which has the 'IsRequired' attribute) will not be included in the POST. Unless disabled, the POST will fail.
104-
foreach (RelationshipAttribute attr in relationshipAttributes)
105-
{
106-
_httpContextAccessor.HttpContext.DisableValidator("Relation", attr.Property.Name);
107-
}
108-
109-
return base.SetRelationships(resource, relationshipsValues, relationshipAttributes);
110-
}
11194
}
11295
}

0 commit comments

Comments
 (0)