Skip to content

Commit 3de17af

Browse files
authored
[AOT] Fixing the problems with ProblemDetails (#45646)
1 parent aaefdc4 commit 3de17af

File tree

8 files changed

+215
-73
lines changed

8 files changed

+215
-73
lines changed

src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetails.cs

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using System.Text.Json.Serialization;
56
using Microsoft.AspNetCore.Http;
67

@@ -12,6 +13,8 @@ namespace Microsoft.AspNetCore.Mvc;
1213
[JsonConverter(typeof(ProblemDetailsJsonConverter))]
1314
public class ProblemDetails
1415
{
16+
private readonly IDictionary<string, object?> _extensions = new Dictionary<string, object?>(StringComparer.Ordinal);
17+
1518
/// <summary>
1619
/// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when
1720
/// dereferenced, it provide human-readable documentation for the problem type
@@ -59,5 +62,10 @@ public class ProblemDetails
5962
/// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters.
6063
/// </remarks>
6164
[JsonExtensionData]
62-
public IDictionary<string, object?> Extensions { get; } = new Dictionary<string, object?>(StringComparer.Ordinal);
65+
public IDictionary<string, object?> Extensions
66+
{
67+
[RequiresUnreferencedCode("JSON serialization and deserialization of ProblemDetails.Extensions might require types that cannot be statically analyzed.")]
68+
[RequiresDynamicCode("JSON serialization and deserialization of ProblemDetails.Extensions might require types that cannot be statically analyzed.")]
69+
get => _extensions;
70+
}
6371
}

src/Http/Http.Abstractions/test/HttpValidationProblemDetailsJsonConverterTest.cs

+34
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,40 @@ public class HttpValidationProblemDetailsJsonConverterTest
1111
{
1212
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().SerializerOptions;
1313

14+
[Fact]
15+
public void Write_Works()
16+
{
17+
var converter = new HttpValidationProblemDetailsJsonConverter();
18+
var problemDetails = new HttpValidationProblemDetails();
19+
20+
problemDetails.Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5";
21+
problemDetails.Title = "Not found";
22+
problemDetails.Status = 404;
23+
problemDetails.Detail = "Product not found";
24+
problemDetails.Instance = "http://example.com/products/14";
25+
problemDetails.Extensions["traceId"] = "|37dd3dd5-4a9619f953c40a16.";
26+
problemDetails.Errors.Add("key0", new[] { "error0" });
27+
problemDetails.Errors.Add("key1", new[] { "error1", "error2" });
28+
29+
var ms = new MemoryStream();
30+
var writer = new Utf8JsonWriter(ms);
31+
converter.Write(writer, problemDetails, JsonSerializerOptions);
32+
writer.Flush();
33+
34+
ms.Seek(0, SeekOrigin.Begin);
35+
var document = JsonDocument.Parse(ms);
36+
Assert.Equal(problemDetails.Type, document.RootElement.GetProperty("type").GetString());
37+
Assert.Equal(problemDetails.Title, document.RootElement.GetProperty("title").GetString());
38+
Assert.Equal(problemDetails.Status, document.RootElement.GetProperty("status").GetInt32());
39+
Assert.Equal(problemDetails.Detail, document.RootElement.GetProperty("detail").GetString());
40+
Assert.Equal(problemDetails.Instance, document.RootElement.GetProperty("instance").GetString());
41+
Assert.Equal((string)problemDetails.Extensions["traceId"]!, document.RootElement.GetProperty("traceId").GetString());
42+
var errorsElement = document.RootElement.GetProperty("errors");
43+
Assert.Equal("error0", errorsElement.GetProperty("key0")[0].GetString());
44+
Assert.Equal("error1", errorsElement.GetProperty("key1")[0].GetString());
45+
Assert.Equal("error2", errorsElement.GetProperty("key1")[1].GetString());
46+
}
47+
1448
[Fact]
1549
public void Read_Works()
1650
{

src/Http/Http.Extensions/src/DefaultProblemDetailsWriter.cs

+24-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics.CodeAnalysis;
5+
using System.Runtime.CompilerServices;
6+
using System.Text.Json;
57
using System.Text.Json.Serialization;
68
using Microsoft.AspNetCore.Mvc;
79
using Microsoft.Extensions.Options;
@@ -43,20 +45,20 @@ public bool CanWrite(ProblemDetailsContext context)
4345
}
4446

4547
[UnconditionalSuppressMessage("Trimming", "IL2026",
46-
Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed and we need to fallback" +
47-
"to reflection-based. The ProblemDetailsConverter is marked as RequiresUnreferencedCode already.")]
48+
Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed. The property is annotated with a warning")]
4849
[UnconditionalSuppressMessage("Trimming", "IL3050",
49-
Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed and we need to fallback" +
50-
"to reflection-based. The ProblemDetailsConverter is marked as RequiresDynamicCode already.")]
50+
Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed. The property is annotated with a warning")]
5151
public ValueTask WriteAsync(ProblemDetailsContext context)
5252
{
5353
var httpContext = context.HttpContext;
5454
ProblemDetailsDefaults.Apply(context.ProblemDetails, httpContext.Response.StatusCode);
5555
_options.CustomizeProblemDetails?.Invoke(context);
5656

57-
if (context.ProblemDetails.Extensions is { Count: 0 })
57+
// Use source generation serialization in two scenarios:
58+
// 1. There are no extensions. Source generation is faster and works well with trimming.
59+
// 2. Native AOT. In this case only the data types specified on ProblemDetailsJsonContext will work.
60+
if (context.ProblemDetails.Extensions is { Count: 0 } || !RuntimeFeature.IsDynamicCodeSupported)
5861
{
59-
// We can use the source generation in this case
6062
return new ValueTask(httpContext.Response.WriteAsJsonAsync(
6163
context.ProblemDetails,
6264
ProblemDetailsJsonContext.Default.ProblemDetails,
@@ -69,7 +71,22 @@ public ValueTask WriteAsync(ProblemDetailsContext context)
6971
contentType: "application/problem+json"));
7072
}
7173

74+
// Additional values are specified on JsonSerializerContext to support some values for extensions.
75+
// For example, the DeveloperExceptionMiddleware serializes its complex type to JsonElement, which problem details then needs to serialize.
7276
[JsonSerializable(typeof(ProblemDetails))]
77+
[JsonSerializable(typeof(JsonElement))]
78+
[JsonSerializable(typeof(string))]
79+
[JsonSerializable(typeof(decimal))]
80+
[JsonSerializable(typeof(float))]
81+
[JsonSerializable(typeof(double))]
82+
[JsonSerializable(typeof(int))]
83+
[JsonSerializable(typeof(long))]
84+
[JsonSerializable(typeof(Guid))]
85+
[JsonSerializable(typeof(Uri))]
86+
[JsonSerializable(typeof(TimeSpan))]
87+
[JsonSerializable(typeof(DateTime))]
88+
[JsonSerializable(typeof(DateTimeOffset))]
7389
internal sealed partial class ProblemDetailsJsonContext : JsonSerializerContext
74-
{ }
90+
{
91+
}
7592
}

src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs

+47-16
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
using System.Diagnostics.CodeAnalysis;
66
using System.Linq;
77
using System.Text;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
810
using Microsoft.AspNetCore.Builder;
911
using Microsoft.AspNetCore.Diagnostics.RazorViews;
1012
using Microsoft.AspNetCore.Hosting;
1113
using Microsoft.AspNetCore.Http;
1214
using Microsoft.AspNetCore.Http.Features;
15+
using Microsoft.AspNetCore.Http.Json;
1316
using Microsoft.AspNetCore.Mvc;
1417
using Microsoft.AspNetCore.Routing;
1518
using Microsoft.Extensions.FileProviders;
@@ -34,6 +37,7 @@ internal class DeveloperExceptionPageMiddlewareImpl
3437
private readonly ExceptionDetailsProvider _exceptionDetailsProvider;
3538
private readonly Func<ErrorContext, Task> _exceptionHandler;
3639
private static readonly MediaTypeHeaderValue _textHtmlMediaType = new MediaTypeHeaderValue("text/html");
40+
private readonly ExtensionsExceptionJsonContext _serializationContext;
3741
private readonly IProblemDetailsService? _problemDetailsService;
3842

3943
/// <summary>
@@ -45,6 +49,7 @@ internal class DeveloperExceptionPageMiddlewareImpl
4549
/// <param name="hostingEnvironment"></param>
4650
/// <param name="diagnosticSource">The <see cref="DiagnosticSource"/> used for writing diagnostic messages.</param>
4751
/// <param name="filters">The list of registered <see cref="IDeveloperPageExceptionFilter"/>.</param>
52+
/// <param name="jsonOptions">The <see cref="JsonOptions"/> used for serialization.</param>
4853
/// <param name="problemDetailsService">The <see cref="IProblemDetailsService"/> used for writing <see cref="ProblemDetails"/> messages.</param>
4954
public DeveloperExceptionPageMiddlewareImpl(
5055
RequestDelegate next,
@@ -53,6 +58,7 @@ public DeveloperExceptionPageMiddlewareImpl(
5358
IWebHostEnvironment hostingEnvironment,
5459
DiagnosticSource diagnosticSource,
5560
IEnumerable<IDeveloperPageExceptionFilter> filters,
61+
IOptions<JsonOptions>? jsonOptions = null,
5662
IProblemDetailsService? problemDetailsService = null)
5763
{
5864
if (next == null)
@@ -77,15 +83,22 @@ public DeveloperExceptionPageMiddlewareImpl(
7783
_diagnosticSource = diagnosticSource;
7884
_exceptionDetailsProvider = new ExceptionDetailsProvider(_fileProvider, _logger, _options.SourceCodeLineCount);
7985
_exceptionHandler = DisplayException;
86+
_serializationContext = CreateSerializationContext(jsonOptions?.Value);
8087
_problemDetailsService = problemDetailsService;
81-
8288
foreach (var filter in filters.Reverse())
8389
{
8490
var nextFilter = _exceptionHandler;
8591
_exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter);
8692
}
8793
}
8894

95+
private static ExtensionsExceptionJsonContext CreateSerializationContext(JsonOptions? jsonOptions)
96+
{
97+
// Create context from configured options to get settings such as PropertyNamePolicy and DictionaryKeyPolicy.
98+
jsonOptions ??= new JsonOptions();
99+
return new ExtensionsExceptionJsonContext(new JsonSerializerOptions(jsonOptions.SerializerOptions));
100+
}
101+
89102
/// <summary>
90103
/// Process an individual request.
91104
/// </summary>
@@ -172,21 +185,7 @@ private async Task DisplayExceptionContent(ErrorContext errorContext)
172185

173186
if (_problemDetailsService != null)
174187
{
175-
var problemDetails = new ProblemDetails
176-
{
177-
Title = TypeNameHelper.GetTypeDisplayName(errorContext.Exception.GetType()),
178-
Detail = errorContext.Exception.Message,
179-
Status = httpContext.Response.StatusCode
180-
};
181-
182-
problemDetails.Extensions["exception"] = new
183-
{
184-
Details = errorContext.Exception.ToString(),
185-
Headers = httpContext.Request.Headers,
186-
Path = httpContext.Request.Path.ToString(),
187-
Endpoint = httpContext.GetEndpoint()?.ToString(),
188-
RouteValues = httpContext.Features.Get<IRouteValuesFeature>()?.RouteValues,
189-
};
188+
var problemDetails = CreateProblemDetails(errorContext, httpContext);
190189

191190
await _problemDetailsService.WriteAsync(new()
192191
{
@@ -214,6 +213,31 @@ await _problemDetailsService.WriteAsync(new()
214213
}
215214
}
216215

216+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Values set on ProblemDetails.Extensions are supported by the default writer.")]
217+
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Values set on ProblemDetails.Extensions are supported by the default writer.")]
218+
private ProblemDetails CreateProblemDetails(ErrorContext errorContext, HttpContext httpContext)
219+
{
220+
var problemDetails = new ProblemDetails
221+
{
222+
Title = TypeNameHelper.GetTypeDisplayName(errorContext.Exception.GetType()),
223+
Detail = errorContext.Exception.Message,
224+
Status = httpContext.Response.StatusCode
225+
};
226+
227+
// Problem details source gen serialization doesn't know about IHeaderDictionary or RouteValueDictionary.
228+
// Serialize payload to a JsonElement here. Problem details serialization can write JsonElement in extensions dictionary.
229+
problemDetails.Extensions["exception"] = JsonSerializer.SerializeToElement(new ExceptionExtensionData
230+
(
231+
Details: errorContext.Exception.ToString(),
232+
Headers: httpContext.Request.Headers,
233+
Path: httpContext.Request.Path.ToString(),
234+
Endpoint: httpContext.GetEndpoint()?.ToString(),
235+
RouteValues: httpContext.Features.Get<IRouteValuesFeature>()?.RouteValues
236+
), _serializationContext.ExceptionExtensionData);
237+
238+
return problemDetails;
239+
}
240+
217241
private Task DisplayCompilationException(
218242
HttpContext context,
219243
ICompilationException compilationException)
@@ -328,3 +352,10 @@ private Task DisplayRuntimeException(HttpContext context, Exception ex)
328352
return errorPage.ExecuteAsync(context);
329353
}
330354
}
355+
356+
internal record ExceptionExtensionData(string Details, IHeaderDictionary Headers, string Path, string? Endpoint, RouteValueDictionary? RouteValues);
357+
358+
[JsonSerializable(typeof(ExceptionExtensionData))]
359+
internal sealed partial class ExtensionsExceptionJsonContext : JsonSerializerContext
360+
{
361+
}

src/Middleware/Diagnostics/test/FunctionalTests/DeveloperExceptionPageSampleTest.cs

+10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net.Http.Headers;
77
using Microsoft.AspNetCore.Mvc;
88
using System.Net.Http.Json;
9+
using System.Text.Json;
910

1011
namespace Microsoft.AspNetCore.Diagnostics.FunctionalTests;
1112

@@ -49,5 +50,14 @@ public async Task DeveloperExceptionPage_ShowsProblemDetails_WhenHtmlNotAccepted
4950
Assert.NotNull(body);
5051
Assert.Equal(500, body.Status);
5152
Assert.Contains("Demonstration exception", body.Detail);
53+
54+
var exceptionNode = (JsonElement)body.Extensions["exception"];
55+
Assert.Contains("System.Exception: Demonstration exception.", exceptionNode.GetProperty("details").GetString());
56+
Assert.Equal("application/json", exceptionNode.GetProperty("headers").GetProperty("Accept")[0].GetString());
57+
Assert.Equal("localhost", exceptionNode.GetProperty("headers").GetProperty("Host")[0].GetString());
58+
Assert.Equal("/", exceptionNode.GetProperty("path").GetString());
59+
Assert.Equal("Endpoint display name", exceptionNode.GetProperty("endpoint").GetString());
60+
Assert.Equal("Value1", exceptionNode.GetProperty("routeValues").GetProperty("routeValue1").GetString());
61+
Assert.Equal("Value2", exceptionNode.GetProperty("routeValues").GetProperty("routeValue2").GetString());
5262
}
5363
}

src/Mvc/Mvc.Core/test/Infrastructure/ValidationProblemDetailsJsonConverterTest.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public void WriteWorks()
148148
using Utf8JsonWriter writer = new(stream);
149149

150150
// Act
151-
converter.Write(writer, problemDetails, null);
151+
converter.Write(writer, problemDetails, new JsonSerializerOptions());
152152

153153
writer.Flush();
154154
var json = Encoding.UTF8.GetString(stream.ToArray());

0 commit comments

Comments
 (0)