Skip to content

Commit e9f43fd

Browse files
author
Bart Koelman
committed
Breaking: Added option to write request body in meta when unable to read it (false by default)
1 parent 094dacd commit e9f43fd

20 files changed

+247
-89
lines changed

src/Examples/JsonApiDotNetCoreExample/Startup.cs

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public void ConfigureServices(IServiceCollection services)
5555
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
5656
#if DEBUG
5757
options.IncludeExceptionStackTraceInErrors = true;
58+
options.IncludeRequestBodyInErrors = true;
5859
#endif
5960
}, discovery => discovery.AddCurrentAssembly());
6061
}

src/Examples/MultiDbContextExample/Startup.cs

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public void ConfigureServices(IServiceCollection services)
2525
services.AddJsonApi(options =>
2626
{
2727
options.IncludeExceptionStackTraceInErrors = true;
28+
options.IncludeRequestBodyInErrors = true;
2829
}, dbContextTypes: new[]
2930
{
3031
typeof(DbContextA),

src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,15 @@ public interface IJsonApiOptions
3030
bool IncludeJsonApiVersion { get; }
3131

3232
/// <summary>
33-
/// Whether or not <see cref="Exception" /> stack traces should be serialized in <see cref="ErrorObject.Meta" />. False by default.
33+
/// Whether or not <see cref="Exception" /> stack traces should be included in <see cref="ErrorObject.Meta" />. False by default.
3434
/// </summary>
3535
bool IncludeExceptionStackTraceInErrors { get; }
3636

37+
/// <summary>
38+
/// Whether or not the request body should be included in <see cref="Document.Meta" /> when it is invalid. False by default.
39+
/// </summary>
40+
bool IncludeRequestBodyInErrors { get; }
41+
3742
/// <summary>
3843
/// Use relative links for all resources. False by default.
3944
/// </summary>

src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ public sealed class JsonApiOptions : IJsonApiOptions
3838
/// <inheritdoc />
3939
public bool IncludeExceptionStackTraceInErrors { get; set; }
4040

41+
/// <inheritdoc />
42+
public bool IncludeRequestBodyInErrors { get; set; }
43+
4144
/// <inheritdoc />
4245
public bool UseRelativeLinks { get; set; }
4346

src/JsonApiDotNetCore/Configuration/ResourceGraph.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public ResourceContext GetResourceContext(string publicName)
5454
/// <inheritdoc />
5555
public ResourceContext TryGetResourceContext(string publicName)
5656
{
57-
ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName));
57+
ArgumentGuard.NotNull(publicName, nameof(publicName));
5858

5959
return _resourceContextsByPublicName.TryGetValue(publicName, out ResourceContext resourceContext) ? resourceContext : null;
6060
}

src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs

+5-14
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,23 @@ namespace JsonApiDotNetCore.Errors
1212
[PublicAPI]
1313
public sealed class InvalidRequestBodyException : JsonApiException
1414
{
15+
public string RequestBody { get; }
16+
1517
public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null)
1618
: base(new ErrorObject(HttpStatusCode.UnprocessableEntity)
1719
{
1820
Title = reason != null ? $"Failed to deserialize request body: {reason}" : "Failed to deserialize request body.",
19-
Detail = FormatErrorDetail(details, requestBody, innerException)
21+
Detail = FormatErrorDetail(details, innerException)
2022
}, innerException)
2123
{
24+
RequestBody = requestBody;
2225
}
2326

24-
private static string FormatErrorDetail(string details, string requestBody, Exception innerException)
27+
private static string FormatErrorDetail(string details, Exception innerException)
2528
{
2629
var builder = new StringBuilder();
2730
builder.Append(details ?? innerException?.Message);
2831

29-
if (requestBody != null)
30-
{
31-
if (builder.Length > 0)
32-
{
33-
builder.Append(" - ");
34-
}
35-
36-
builder.Append("Request body: <<");
37-
builder.Append(requestBody);
38-
builder.Append(">>");
39-
}
40-
4132
return builder.Length > 0 ? builder.ToString() : null;
4233
}
4334
}

src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs

+22-11
Original file line numberDiff line numberDiff line change
@@ -84,31 +84,42 @@ protected virtual Document CreateErrorDocument(Exception exception)
8484
Detail = exception.Message
8585
}.AsArray();
8686

87-
foreach (ErrorObject error in errors)
87+
var document = new Document
8888
{
89-
ApplyOptions(error, exception);
89+
Errors = errors.ToList()
90+
};
91+
92+
if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException)
93+
{
94+
IncludeStackTraces(exception, document.Errors);
9095
}
9196

92-
return new Document
97+
if (_options.IncludeRequestBodyInErrors && exception is InvalidRequestBodyException { RequestBody: { } } invalidRequestBodyException)
9398
{
94-
Errors = errors.ToList()
95-
};
99+
IncludeRequestBody(invalidRequestBodyException, document);
100+
}
101+
102+
return document;
96103
}
97104

98-
private void ApplyOptions(ErrorObject error, Exception exception)
105+
private void IncludeStackTraces(Exception exception, IList<ErrorObject> errors)
99106
{
100-
Exception resultException = exception is InvalidModelStateException ? null : exception;
107+
string[] stackTraceLines = exception.StackTrace?.Split(Environment.NewLine);
101108

102-
if (resultException != null && _options.IncludeExceptionStackTraceInErrors)
109+
if (!stackTraceLines.IsNullOrEmpty())
103110
{
104-
string[] stackTraceLines = exception.StackTrace?.Split(Environment.NewLine);
105-
106-
if (!stackTraceLines.IsNullOrEmpty())
111+
foreach (ErrorObject error in errors)
107112
{
108113
error.Meta ??= new Dictionary<string, object>();
109114
error.Meta["StackTrace"] = stackTraceLines;
110115
}
111116
}
112117
}
118+
119+
private static void IncludeRequestBody(InvalidRequestBodyException exception, Document document)
120+
{
121+
document.Meta ??= new Dictionary<string, object>();
122+
document.Meta["RequestBody"] = exception.RequestBody;
123+
}
113124
}
114125
}

src/JsonApiDotNetCore/Serialization/JsonApiReader.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,7 @@ private InvalidRequestBodyException ToInvalidRequestBodyException(JsonApiSeriali
9999
return new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception);
100100
}
101101

102-
// In contrast to resource endpoints, we don't include the request body for operations because they are usually very long.
103-
var requestException = new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, null, exception.InnerException);
102+
var requestException = new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception.InnerException);
104103

105104
if (exception.AtomicOperationIndex != null)
106105
{

test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs

+36
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
8080
error.Detail.Should().Be("Article with code 'X123' is no longer available.");
8181
((JsonElement)error.Meta["support"]).GetString().Should().Be("Please contact us for info about similar articles at [email protected].");
8282

83+
responseDocument.Meta.Should().BeNull();
84+
8385
loggerFactory.Logger.Messages.Should().HaveCount(1);
8486
loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning);
8587
loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available.");
8688
}
8789

90+
[Fact]
91+
public async Task Logs_and_produces_error_response_on_deserialization_failure()
92+
{
93+
// Arrange
94+
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();
95+
loggerFactory.Logger.Clear();
96+
97+
const string requestBody = @"{ ""data"": { ""type"": """" } }";
98+
99+
const string route = "/consumerArticles";
100+
101+
// Act
102+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);
103+
104+
// Assert
105+
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
106+
107+
responseDocument.Errors.Should().HaveCount(1);
108+
109+
ErrorObject error = responseDocument.Errors[0];
110+
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
111+
error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type.");
112+
error.Detail.Should().Be("Resource type '' does not exist.");
113+
114+
IEnumerable<string> stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString());
115+
stackTraceLines.Should().NotBeEmpty();
116+
117+
responseDocument.Meta["requestBody"].ToString().Should().Be(requestBody);
118+
119+
loggerFactory.Logger.Messages.Should().BeEmpty();
120+
}
121+
88122
[Fact]
89123
public async Task Logs_and_produces_error_response_on_serialization_failure()
90124
{
@@ -118,6 +152,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
118152
IEnumerable<string> stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString());
119153
stackTraceLines.Should().ContainMatch("*at object System.Reflection.*");
120154

155+
responseDocument.Meta.Should().BeNull();
156+
121157
loggerFactory.Logger.Messages.Should().HaveCount(1);
122158
loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error);
123159
loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation.");

test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs

+17-7
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,9 @@ public async Task Cannot_create_resource_for_missing_type()
423423
ErrorObject error = responseDocument.Errors[0];
424424
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
425425
error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element.");
426-
error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<");
426+
error.Detail.Should().Be("Expected 'type' element in 'data' element.");
427+
428+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
427429
}
428430

429431
[Fact]
@@ -454,7 +456,9 @@ public async Task Cannot_create_resource_for_unknown_type()
454456
ErrorObject error = responseDocument.Errors[0];
455457
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
456458
error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type.");
457-
error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<");
459+
error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist.");
460+
461+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
458462
}
459463

460464
[Fact]
@@ -541,7 +545,9 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability()
541545
ErrorObject error = responseDocument.Errors[0];
542546
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
543547
error.Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed.");
544-
error.Detail.Should().StartWith("Setting the initial value of 'isImportant' is not allowed. - Request body: <<");
548+
error.Detail.Should().Be("Setting the initial value of 'isImportant' is not allowed.");
549+
550+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
545551
}
546552

547553
[Fact]
@@ -573,7 +579,9 @@ public async Task Cannot_create_resource_with_readonly_attribute()
573579
ErrorObject error = responseDocument.Errors[0];
574580
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
575581
error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only.");
576-
error.Detail.Should().StartWith("Attribute 'isDeprecated' is read-only. - Request body: <<");
582+
error.Detail.Should().Be("Attribute 'isDeprecated' is read-only.");
583+
584+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
577585
}
578586

579587
[Fact]
@@ -595,7 +603,9 @@ public async Task Cannot_create_resource_for_broken_JSON_request_body()
595603
ErrorObject error = responseDocument.Errors[0];
596604
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
597605
error.Title.Should().Be("Failed to deserialize request body.");
598-
error.Detail.Should().Match("'{' is invalid after a property name. * - Request body: <<*");
606+
error.Detail.Should().StartWith("'{' is invalid after a property name.");
607+
608+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
599609
}
600610

601611
[Fact]
@@ -627,9 +637,9 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value()
627637
ErrorObject error = responseDocument.Errors[0];
628638
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
629639
error.Title.Should().Be("Failed to deserialize request body.");
640+
error.Detail.Should().Be("Failed to convert attribute 'dueAt' with value 'not-a-valid-time' of type 'String' to type 'Nullable<DateTimeOffset>'.");
630641

631-
error.Detail.Should().StartWith("Failed to convert attribute 'dueAt' with value 'not-a-valid-time' " +
632-
"of type 'String' to type 'Nullable<DateTimeOffset>'. - Request body: <<");
642+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
633643
}
634644

635645
[Fact]

test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs

+21-7
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ public async Task Cannot_create_for_missing_relationship_type()
358358
ErrorObject error = responseDocument.Errors[0];
359359
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
360360
error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element.");
361-
error.Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<");
361+
error.Detail.Should().Be("Expected 'type' element in 'subscribers' relationship.");
362+
363+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
362364
}
363365

364366
[Fact]
@@ -400,7 +402,9 @@ public async Task Cannot_create_for_unknown_relationship_type()
400402
ErrorObject error = responseDocument.Errors[0];
401403
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
402404
error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type.");
403-
error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<");
405+
error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist.");
406+
407+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
404408
}
405409

406410
[Fact]
@@ -441,7 +445,9 @@ public async Task Cannot_create_for_missing_relationship_ID()
441445
ErrorObject error = responseDocument.Errors[0];
442446
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
443447
error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' element.");
444-
error.Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<");
448+
error.Detail.Should().Be("Expected 'id' element in 'subscribers' relationship.");
449+
450+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
445451
}
446452

447453
[Fact]
@@ -538,7 +544,9 @@ public async Task Cannot_create_on_relationship_type_mismatch()
538544
ErrorObject error = responseDocument.Errors[0];
539545
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
540546
error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type.");
541-
error.Detail.Should().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<");
547+
error.Detail.Should().Be("Relationship 'subscribers' contains incompatible resource type 'rgbColors'.");
548+
549+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
542550
}
543551

544552
[Fact]
@@ -639,7 +647,9 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship()
639647
ErrorObject error = responseDocument.Errors[0];
640648
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
641649
error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship.");
642-
error.Detail.Should().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<");
650+
error.Detail.Should().Be("Expected data[] element for 'subscribers' relationship.");
651+
652+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
643653
}
644654

645655
[Fact]
@@ -674,7 +684,9 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship()
674684
ErrorObject error = responseDocument.Errors[0];
675685
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
676686
error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship.");
677-
error.Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<");
687+
error.Detail.Should().Be("Expected data[] element for 'tags' relationship.");
688+
689+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
678690
}
679691

680692
[Fact]
@@ -719,7 +731,9 @@ public async Task Cannot_create_resource_with_local_ID()
719731
ErrorObject error = responseDocument.Errors[0];
720732
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
721733
error.Title.Should().Be("Failed to deserialize request body.");
722-
error.Detail.Should().StartWith("Local IDs cannot be used at this endpoint. - Request body: <<");
734+
error.Detail.Should().Be("Local IDs cannot be used at this endpoint.");
735+
736+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
723737
}
724738
}
725739
}

0 commit comments

Comments
 (0)