Skip to content

Commit fce57e1

Browse files
author
Bart Koelman
committed
Refactored JsonApiException to contain a list of errors.
This makes it possible to catch one base exception type, regardless of how many errors are returned in the response.
1 parent 6006c46 commit fce57e1

15 files changed

+105
-91
lines changed

Diff for: src/JsonApiDotNetCore/Errors/IHasMultipleErrors.cs

-10
This file was deleted.

Diff for: src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs

+7-9
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,21 @@ namespace JsonApiDotNetCore.Errors
1313
/// <summary>
1414
/// The error that is thrown when model state validation fails.
1515
/// </summary>
16-
public class InvalidModelStateException : Exception, IHasMultipleErrors
16+
public class InvalidModelStateException : JsonApiException
1717
{
18-
public IReadOnlyCollection<Error> Errors { get; }
19-
2018
public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType,
2119
bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy)
20+
: base(FromModelState(modelState, resourceType, includeExceptionStackTraceInErrors, namingStrategy))
2221
{
23-
if (modelState == null) throw new ArgumentNullException(nameof(modelState));
24-
if (resourceType == null) throw new ArgumentNullException(nameof(resourceType));
25-
if (namingStrategy == null) throw new ArgumentNullException(nameof(namingStrategy));
26-
27-
Errors = FromModelState(modelState, resourceType, includeExceptionStackTraceInErrors, namingStrategy);
2822
}
2923

3024
private static IReadOnlyCollection<Error> FromModelState(ModelStateDictionary modelState, Type resourceType,
3125
bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy)
3226
{
27+
if (modelState == null) throw new ArgumentNullException(nameof(modelState));
28+
if (resourceType == null) throw new ArgumentNullException(nameof(resourceType));
29+
if (namingStrategy == null) throw new ArgumentNullException(nameof(namingStrategy));
30+
3331
List<Error> errors = new List<Error>();
3432

3533
foreach (var (propertyName, entry) in modelState.Where(x => x.Value.Errors.Any()))
@@ -40,7 +38,7 @@ private static IReadOnlyCollection<Error> FromModelState(ModelStateDictionary mo
4038
{
4139
if (modelError.Exception is JsonApiException jsonApiException)
4240
{
43-
errors.Add(jsonApiException.Error);
41+
errors.AddRange(jsonApiException.Errors);
4442
}
4543
else
4644
{

Diff for: src/JsonApiDotNetCore/Errors/JsonApiException.cs

+22-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
24
using JsonApiDotNetCore.Serialization.Objects;
35
using Newtonsoft.Json;
46

57
namespace JsonApiDotNetCore.Errors
68
{
79
/// <summary>
8-
/// The base class for an <see cref="Exception"/> that represents a json:api error object in an unsuccessful response.
10+
/// The base class for an <see cref="Exception"/> that represents one or more json:api error objects in an unsuccessful response.
911
/// </summary>
1012
public class JsonApiException : Exception
1113
{
@@ -15,14 +17,29 @@ public class JsonApiException : Exception
1517
Formatting = Formatting.Indented
1618
};
1719

18-
public Error Error { get; }
20+
public IReadOnlyList<Error> Errors { get; }
1921

2022
public JsonApiException(Error error, Exception innerException = null)
21-
: base(error.Title, innerException)
23+
: base(null, innerException)
2224
{
23-
Error = error;
25+
if (error == null) throw new ArgumentNullException(nameof(error));
26+
27+
Errors = new[] {error};
28+
}
29+
30+
public JsonApiException(IEnumerable<Error> errors, Exception innerException = null)
31+
: base(null, innerException)
32+
{
33+
if (errors == null) throw new ArgumentNullException(nameof(errors));
34+
35+
Errors = errors.ToList();
36+
37+
if (!Errors.Any())
38+
{
39+
throw new ArgumentException("At least one error is required.", nameof(errors));
40+
}
2441
}
2542

26-
public override string Message => "Error = " + JsonConvert.SerializeObject(Error, _errorSerializerSettings);
43+
public override string Message => "Errors = " + JsonConvert.SerializeObject(Errors, _errorSerializerSettings);
2744
}
2845
}

Diff for: src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs

+3-5
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@ namespace JsonApiDotNetCore.Errors
99
/// <summary>
1010
/// The error that is thrown when referencing one or more non-existing resources in one or more relationships.
1111
/// </summary>
12-
public sealed class ResourcesInRelationshipsNotFoundException : Exception, IHasMultipleErrors
12+
public sealed class ResourcesInRelationshipsNotFoundException : JsonApiException
1313
{
14-
public IReadOnlyCollection<Error> Errors { get; }
15-
1614
public ResourcesInRelationshipsNotFoundException(IEnumerable<MissingResourceInRelationship> missingResources)
15+
: base(missingResources.Select(CreateError))
1716
{
18-
Errors = missingResources.Select(CreateError).ToList();
1917
}
2018

21-
private Error CreateError(MissingResourceInRelationship missingResourceInRelationship)
19+
private static Error CreateError(MissingResourceInRelationship missingResourceInRelationship)
2220
{
2321
return new Error(HttpStatusCode.NotFound)
2422
{

Diff for: src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs

+20-18
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ protected virtual LogLevel GetLogLevel(Exception exception)
5151
return LogLevel.None;
5252
}
5353

54-
if (exception is JsonApiException || exception is InvalidModelStateException)
54+
if (exception is JsonApiException)
5555
{
5656
return LogLevel.Information;
5757
}
@@ -63,36 +63,38 @@ protected virtual string GetLogMessage(Exception exception)
6363
{
6464
if (exception == null) throw new ArgumentNullException(nameof(exception));
6565

66-
return exception is JsonApiException jsonApiException
67-
? jsonApiException.Error.Title
68-
: exception.Message;
66+
return exception.Message;
6967
}
7068

7169
protected virtual ErrorDocument CreateErrorDocument(Exception exception)
7270
{
7371
if (exception == null) throw new ArgumentNullException(nameof(exception));
7472

75-
if (exception is IHasMultipleErrors exceptionWithMultipleErrors)
76-
{
77-
return new ErrorDocument(exceptionWithMultipleErrors.Errors);
78-
}
79-
80-
Error error = exception is JsonApiException jsonApiException
81-
? jsonApiException.Error
73+
var errors = exception is JsonApiException jsonApiException
74+
? jsonApiException.Errors
8275
: exception is TaskCanceledException
83-
? new Error((HttpStatusCode) 499)
76+
? new[]
8477
{
85-
Title = "Request execution was canceled."
78+
new Error((HttpStatusCode) 499)
79+
{
80+
Title = "Request execution was canceled."
81+
}
8682
}
87-
: new Error(HttpStatusCode.InternalServerError)
83+
: new[]
8884
{
89-
Title = "An unhandled error occurred while processing this request.",
90-
Detail = exception.Message
85+
new Error(HttpStatusCode.InternalServerError)
86+
{
87+
Title = "An unhandled error occurred while processing this request.",
88+
Detail = exception.Message
89+
}
9190
};
9291

93-
ApplyOptions(error, exception);
92+
foreach (var error in errors)
93+
{
94+
ApplyOptions(error, exception);
95+
}
9496

95-
return new ErrorDocument(error);
97+
return new ErrorDocument(errors);
9698
}
9799

98100
private void ApplyOptions(Error error, Exception exception)

Diff for: test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ protected override ErrorDocument CreateErrorDocument(Exception exception)
5555
{
5656
if (exception is NoPermissionException noPermissionException)
5757
{
58-
noPermissionException.Error.Meta.Data.Add("support",
58+
noPermissionException.Errors[0].Meta.Data.Add("support",
5959
"For support, email to: [email protected]?subject=" + noPermissionException.CustomerCode);
6060
}
6161

Diff for: test/UnitTests/Controllers/BaseJsonApiController_Tests.cs

+7-7
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public async Task GetAsync_Throws_405_If_No_Service()
7676
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.GetAsync(CancellationToken.None));
7777

7878
// Assert
79-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
79+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
8080
Assert.Equal(HttpMethod.Get, exception.Method);
8181
}
8282

@@ -106,7 +106,7 @@ public async Task GetAsyncById_Throws_405_If_No_Service()
106106
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.GetAsync(id, CancellationToken.None));
107107

108108
// Assert
109-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
109+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
110110
Assert.Equal(HttpMethod.Get, exception.Method);
111111
}
112112

@@ -136,7 +136,7 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service()
136136
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.GetRelationshipAsync(id, string.Empty, CancellationToken.None));
137137

138138
// Assert
139-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
139+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
140140
Assert.Equal(HttpMethod.Get, exception.Method);
141141
}
142142

@@ -166,7 +166,7 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service()
166166
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.GetSecondaryAsync(id, string.Empty, CancellationToken.None));
167167

168168
// Assert
169-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
169+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
170170
Assert.Equal(HttpMethod.Get, exception.Method);
171171
}
172172

@@ -199,7 +199,7 @@ public async Task PatchAsync_Throws_405_If_No_Service()
199199
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.PatchAsync(id, resource, CancellationToken.None));
200200

201201
// Assert
202-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
202+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
203203
Assert.Equal(HttpMethod.Patch, exception.Method);
204204
}
205205

@@ -247,7 +247,7 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service()
247247
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.PatchRelationshipAsync(id, string.Empty, null, CancellationToken.None));
248248

249249
// Assert
250-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
250+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
251251
Assert.Equal(HttpMethod.Patch, exception.Method);
252252
}
253253

@@ -277,7 +277,7 @@ public async Task DeleteAsync_Throws_405_If_No_Service()
277277
var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(() => controller.DeleteAsync(id, CancellationToken.None));
278278

279279
// Assert
280-
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode);
280+
Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode);
281281
Assert.Equal(HttpMethod.Delete, exception.Method);
282282
}
283283
}

Diff for: test/UnitTests/QueryStringParameters/DefaultsParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
7575
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
7676

7777
exception.QueryParameterName.Should().Be(parameterName);
78-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
79-
exception.Error.Title.Should().Be("The specified defaults is invalid.");
80-
exception.Error.Detail.Should().Be(errorMessage);
81-
exception.Error.Source.Parameter.Should().Be(parameterName);
78+
exception.Errors.Should().HaveCount(1);
79+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
80+
exception.Errors[0].Title.Should().Be("The specified defaults is invalid.");
81+
exception.Errors[0].Detail.Should().Be(errorMessage);
82+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
8283
}
8384

8485
[Theory]

Diff for: test/UnitTests/QueryStringParameters/FilterParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
9898
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
9999

100100
exception.QueryParameterName.Should().Be(parameterName);
101-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
102-
exception.Error.Title.Should().Be("The specified filter is invalid.");
103-
exception.Error.Detail.Should().Be(errorMessage);
104-
exception.Error.Source.Parameter.Should().Be(parameterName);
101+
exception.Errors.Should().HaveCount(1);
102+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
103+
exception.Errors[0].Title.Should().Be("The specified filter is invalid.");
104+
exception.Errors[0].Detail.Should().Be(errorMessage);
105+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
105106
}
106107

107108
[Theory]

Diff for: test/UnitTests/QueryStringParameters/IncludeParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
6464
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
6565

6666
exception.QueryParameterName.Should().Be(parameterName);
67-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
68-
exception.Error.Title.Should().Be("The specified include is invalid.");
69-
exception.Error.Detail.Should().Be(errorMessage);
70-
exception.Error.Source.Parameter.Should().Be(parameterName);
67+
exception.Errors.Should().HaveCount(1);
68+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
69+
exception.Errors[0].Title.Should().Be("The specified include is invalid.");
70+
exception.Errors[0].Detail.Should().Be(errorMessage);
71+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
7172
}
7273

7374
[Theory]

Diff for: test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
4848
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
4949

5050
exception.QueryParameterName.Should().Be(parameterName);
51-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
52-
exception.Error.Title.Should().Be("The specified filter is invalid.");
53-
exception.Error.Detail.Should().Be(errorMessage);
54-
exception.Error.Source.Parameter.Should().Be(parameterName);
51+
exception.Errors.Should().HaveCount(1);
52+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
53+
exception.Errors[0].Title.Should().Be("The specified filter is invalid.");
54+
exception.Errors[0].Detail.Should().Be(errorMessage);
55+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
5556
}
5657

5758
[Theory]

Diff for: test/UnitTests/QueryStringParameters/NullsParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
7575
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
7676

7777
exception.QueryParameterName.Should().Be(parameterName);
78-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
79-
exception.Error.Title.Should().Be("The specified nulls is invalid.");
80-
exception.Error.Detail.Should().Be(errorMessage);
81-
exception.Error.Source.Parameter.Should().Be(parameterName);
78+
exception.Errors.Should().HaveCount(1);
79+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
80+
exception.Errors[0].Title.Should().Be("The specified nulls is invalid.");
81+
exception.Errors[0].Detail.Should().Be(errorMessage);
82+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
8283
}
8384

8485
[Theory]

Diff for: test/UnitTests/QueryStringParameters/PaginationParseTests.cs

+10-8
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes
7676
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
7777

7878
exception.QueryParameterName.Should().Be("page[number]");
79-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
80-
exception.Error.Title.Should().Be("The specified paging is invalid.");
81-
exception.Error.Detail.Should().Be(errorMessage);
82-
exception.Error.Source.Parameter.Should().Be("page[number]");
79+
exception.Errors.Should().HaveCount(1);
80+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
81+
exception.Errors[0].Title.Should().Be("The specified paging is invalid.");
82+
exception.Errors[0].Detail.Should().Be(errorMessage);
83+
exception.Errors[0].Source.Parameter.Should().Be("page[number]");
8384
}
8485

8586
[Theory]
@@ -108,10 +109,11 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa
108109
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
109110

110111
exception.QueryParameterName.Should().Be("page[size]");
111-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
112-
exception.Error.Title.Should().Be("The specified paging is invalid.");
113-
exception.Error.Detail.Should().Be(errorMessage);
114-
exception.Error.Source.Parameter.Should().Be("page[size]");
112+
exception.Errors.Should().HaveCount(1);
113+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
114+
exception.Errors[0].Title.Should().Be("The specified paging is invalid.");
115+
exception.Errors[0].Detail.Should().Be(errorMessage);
116+
exception.Errors[0].Source.Parameter.Should().Be("page[size]");
115117
}
116118

117119
[Theory]

Diff for: test/UnitTests/QueryStringParameters/SortParseTests.cs

+5-4
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
7979
var exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
8080

8181
exception.QueryParameterName.Should().Be(parameterName);
82-
exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
83-
exception.Error.Title.Should().Be("The specified sort is invalid.");
84-
exception.Error.Detail.Should().Be(errorMessage);
85-
exception.Error.Source.Parameter.Should().Be(parameterName);
82+
exception.Errors.Should().HaveCount(1);
83+
exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest);
84+
exception.Errors[0].Title.Should().Be("The specified sort is invalid.");
85+
exception.Errors[0].Detail.Should().Be(errorMessage);
86+
exception.Errors[0].Source.Parameter.Should().Be(parameterName);
8687
}
8788

8889
[Theory]

0 commit comments

Comments
 (0)