Skip to content

Commit c788cd7

Browse files
author
Bart Koelman
committed
Respect configured MaxModelValidationErrors in operations
1 parent 3a387cb commit c788cd7

File tree

5 files changed

+189
-27
lines changed

5 files changed

+189
-27
lines changed

src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,48 +133,79 @@ protected virtual void ValidateModelState(IList<OperationContainer> operations)
133133
using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields);
134134

135135
int operationIndex = 0;
136-
var requestModelState = new Dictionary<string, ModelStateEntry>();
136+
var requestModelState = new List<(string key, ModelStateEntry entry)>();
137+
int maxErrorsRemaining = ModelState.MaxAllowedErrors;
137138

138139
foreach (OperationContainer operation in operations)
139140
{
140-
if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource)
141+
if (maxErrorsRemaining < 1)
141142
{
142-
_targetedFields.CopyFrom(operation.TargetedFields);
143-
_request.CopyFrom(operation.Request);
144-
145-
var validationContext = new ActionContext();
146-
ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource);
147-
148-
CopyValidationErrorsFromOperation(validationContext.ModelState, operationIndex, requestModelState);
143+
break;
149144
}
150145

146+
maxErrorsRemaining = ValidateOperation(operation, operationIndex, requestModelState, maxErrorsRemaining);
147+
151148
operationIndex++;
152149
}
153150

154151
if (requestModelState.Any())
155152
{
156-
throw new InvalidModelStateException(requestModelState, typeof(IList<OperationContainer>), _options.IncludeExceptionStackTraceInErrors,
153+
Dictionary<string, ModelStateEntry> modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry);
154+
155+
throw new InvalidModelStateException(modelStateDictionary, typeof(IList<OperationContainer>), _options.IncludeExceptionStackTraceInErrors,
157156
_resourceGraph,
158157
(collectionType, index) => collectionType == typeof(IList<OperationContainer>) ? operations[index].Resource.GetType() : null);
159158
}
160159
}
161160

162-
private static void CopyValidationErrorsFromOperation(ModelStateDictionary operationModelState, int operationIndex,
163-
Dictionary<string, ModelStateEntry> requestModelState)
161+
private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry entry)> requestModelState,
162+
int maxErrorsRemaining)
164163
{
165-
if (!operationModelState.IsValid)
164+
if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource)
166165
{
167-
foreach (string key in operationModelState.Keys)
166+
_targetedFields.CopyFrom(operation.TargetedFields);
167+
_request.CopyFrom(operation.Request);
168+
169+
var validationContext = new ActionContext
170+
{
171+
ModelState =
172+
{
173+
MaxAllowedErrors = maxErrorsRemaining
174+
}
175+
};
176+
177+
ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource);
178+
179+
if (!validationContext.ModelState.IsValid)
168180
{
169-
ModelStateEntry entry = operationModelState[key];
181+
int errorsRemaining = maxErrorsRemaining;
170182

171-
if (entry.ValidationState == ModelValidationState.Invalid)
183+
foreach (string key in validationContext.ModelState.Keys)
172184
{
173-
string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}." + key;
174-
requestModelState[operationKey] = entry;
185+
ModelStateEntry entry = validationContext.ModelState[key];
186+
187+
if (entry.ValidationState == ModelValidationState.Invalid)
188+
{
189+
string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}." + key;
190+
191+
if (entry.Errors.Count > 0 && entry.Errors[0].Exception is TooManyModelErrorsException)
192+
{
193+
requestModelState.Insert(0, (operationKey, entry));
194+
}
195+
else
196+
{
197+
requestModelState.Add((operationKey, entry));
198+
}
199+
200+
errorsRemaining -= entry.Errors.Count;
201+
}
175202
}
203+
204+
return errorsRemaining;
176205
}
177206
}
207+
208+
return maxErrorsRemaining;
178209
}
179210
}
180211
}

src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ public InvalidModelStateException(IReadOnlyDictionary<string, ModelStateEntry> m
2626
{
2727
}
2828

29-
private static IEnumerable<ErrorObject> FromModelStateDictionary(IReadOnlyDictionary<string, ModelStateEntry> modelState, Type modelType, IResourceGraph resourceGraph,
30-
bool includeExceptionStackTraceInErrors, Func<Type, int, Type?>? getCollectionElementTypeCallback)
29+
private static IEnumerable<ErrorObject> FromModelStateDictionary(IReadOnlyDictionary<string, ModelStateEntry> modelState, Type modelType,
30+
IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func<Type, int, Type?>? getCollectionElementTypeCallback)
3131
{
3232
ArgumentGuard.NotNull(modelState, nameof(modelState));
3333
ArgumentGuard.NotNull(modelType, nameof(modelType));
@@ -179,7 +179,7 @@ private static ErrorObject FromModelError(ModelError modelError, string? sourceP
179179
var error = new ErrorObject(HttpStatusCode.UnprocessableEntity)
180180
{
181181
Title = "Input validation failed.",
182-
Detail = modelError.ErrorMessage,
182+
Detail = modelError.Exception is TooManyModelErrorsException tooManyException ? tooManyException.Message : modelError.ErrorMessage,
183183
Source = sourcePointer == null
184184
? null
185185
: new ErrorSource

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ public async Task Validates_all_operations_before_execution_starts()
448448
type = "musicTracks",
449449
attributes = new
450450
{
451+
title = "some",
451452
lengthInSeconds = -1
452453
}
453454
}
@@ -463,7 +464,7 @@ public async Task Validates_all_operations_before_execution_starts()
463464
// Assert
464465
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
465466

466-
responseDocument.Errors.Should().HaveCount(3);
467+
responseDocument.Errors.Should().HaveCount(2);
467468

468469
ErrorObject error1 = responseDocument.Errors[0];
469470
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
@@ -474,14 +475,96 @@ public async Task Validates_all_operations_before_execution_starts()
474475
ErrorObject error2 = responseDocument.Errors[1];
475476
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
476477
error2.Title.Should().Be("Input validation failed.");
477-
error2.Detail.Should().Be("The Title field is required.");
478-
error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/title");
478+
error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440.");
479+
error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds");
480+
}
481+
482+
[Fact]
483+
public async Task Does_not_exceed_MaxModelValidationErrors()
484+
{
485+
// Arrange
486+
var requestBody = new
487+
{
488+
atomic__operations = new object[]
489+
{
490+
new
491+
{
492+
op = "add",
493+
data = new
494+
{
495+
type = "playlists",
496+
attributes = new
497+
{
498+
name = (string)null
499+
}
500+
}
501+
},
502+
new
503+
{
504+
op = "add",
505+
data = new
506+
{
507+
type = "playlists",
508+
attributes = new
509+
{
510+
name = (string)null
511+
}
512+
}
513+
},
514+
new
515+
{
516+
op = "add",
517+
data = new
518+
{
519+
type = "musicTracks",
520+
attributes = new
521+
{
522+
lengthInSeconds = -1
523+
}
524+
}
525+
},
526+
new
527+
{
528+
op = "add",
529+
data = new
530+
{
531+
type = "playlists",
532+
attributes = new
533+
{
534+
name = (string)null
535+
}
536+
}
537+
}
538+
}
539+
};
540+
541+
const string route = "/operations";
542+
543+
// Act
544+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(route, requestBody);
545+
546+
// Assert
547+
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
548+
549+
responseDocument.Errors.Should().HaveCount(3);
550+
551+
ErrorObject error1 = responseDocument.Errors[0];
552+
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
553+
error1.Title.Should().Be("Input validation failed.");
554+
error1.Detail.Should().Be("The maximum number of allowed model errors has been reached.");
555+
error1.Source.Should().BeNull();
556+
557+
ErrorObject error2 = responseDocument.Errors[1];
558+
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
559+
error2.Title.Should().Be("Input validation failed.");
560+
error2.Detail.Should().Be("The Name field is required.");
561+
error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name");
479562

480563
ErrorObject error3 = responseDocument.Errors[2];
481564
error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
482565
error3.Title.Should().Be("Input validation failed.");
483-
error3.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440.");
484-
error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds");
566+
error2.Detail.Should().Be("The Name field is required.");
567+
error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name");
485568
}
486569
}
487570
}

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ public async Task Cannot_create_resource_with_multiple_violations()
167167
type = "systemDirectories",
168168
attributes = new
169169
{
170+
isCaseSensitive = false,
170171
sizeInBytes = -1
171172
}
172173
}
@@ -180,7 +181,7 @@ public async Task Cannot_create_resource_with_multiple_violations()
180181
// Assert
181182
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
182183

183-
responseDocument.Errors.Should().HaveCount(3);
184+
responseDocument.Errors.Should().HaveCount(2);
184185

185186
ErrorObject error1 = responseDocument.Errors[0];
186187
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
@@ -193,6 +194,45 @@ public async Task Cannot_create_resource_with_multiple_violations()
193194
error2.Title.Should().Be("Input validation failed.");
194195
error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807.");
195196
error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes");
197+
}
198+
199+
[Fact]
200+
public async Task Does_not_exceed_MaxModelValidationErrors()
201+
{
202+
// Arrange
203+
var requestBody = new
204+
{
205+
data = new
206+
{
207+
type = "systemDirectories",
208+
attributes = new
209+
{
210+
sizeInBytes = -1
211+
}
212+
}
213+
};
214+
215+
const string route = "/systemDirectories";
216+
217+
// Act
218+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);
219+
220+
// Assert
221+
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
222+
223+
responseDocument.Errors.Should().HaveCount(3);
224+
225+
ErrorObject error1 = responseDocument.Errors[0];
226+
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
227+
error1.Title.Should().Be("Input validation failed.");
228+
error1.Detail.Should().Be("The maximum number of allowed model errors has been reached.");
229+
error1.Source.Should().BeNull();
230+
231+
ErrorObject error2 = responseDocument.Errors[1];
232+
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
233+
error2.Title.Should().Be("Input validation failed.");
234+
error2.Detail.Should().Be("The Name field is required.");
235+
error2.Source.Pointer.Should().Be("/data/attributes/directoryName");
196236

197237
ErrorObject error3 = responseDocument.Errors[2];
198238
error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);

test/JsonApiDotNetCoreTests/Startups/ModelStateValidationStartup.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using JetBrains.Annotations;
44
using JsonApiDotNetCore.Configuration;
55
using Microsoft.EntityFrameworkCore;
6+
using Microsoft.Extensions.DependencyInjection;
67
using TestBuildingBlocks;
78

89
namespace JsonApiDotNetCoreTests.Startups
@@ -11,6 +12,13 @@ namespace JsonApiDotNetCoreTests.Startups
1112
public sealed class ModelStateValidationStartup<TDbContext> : TestableStartup<TDbContext>
1213
where TDbContext : DbContext
1314
{
15+
public override void ConfigureServices(IServiceCollection services)
16+
{
17+
IMvcCoreBuilder mvcBuilder = services.AddMvcCore(options => options.MaxModelValidationErrors = 3);
18+
19+
services.AddJsonApi<TDbContext>(SetJsonApiOptions, mvcBuilder: mvcBuilder);
20+
}
21+
1422
protected override void SetJsonApiOptions(JsonApiOptions options)
1523
{
1624
base.SetJsonApiOptions(options);

0 commit comments

Comments
 (0)