Skip to content

Commit ac67d32

Browse files
brunolins16BrennanConroyhalter73
authored
Adding ProblemDetailsService (#42384)
* MVC Changes * ExceptionHandler changes * Routing changes * Http.Extensions changes * Minimal APi draft changes * HttpResults changes * New ProblemDetails project * Using ProblemDetailsOptions in mvc * ProblemDetails project simplification * Using metadata * Using metadata * Latest version * Removing changes * Initial cleanup * Clean up * Public api clean up * Updating public API * More clean ups * Adding initial unit tests * Updating unit tests * Adding more unit tests * Cleanup * Clean up * clean up * clean up * Removing nullable * Simplifying public api * API Review feedback * API review feedback * Clean up * Clean up * clean up * Reusing Endpoint & EndpointMetadataCollection * Fix build issues * Updates based on docs/Trimming.md * Adding Functional tests * Fix unittest * Seal context * Seal ProblemMetadata * PR Feeback * API Review * Clean up * Fixing publicapi warnings * Clean up * PR Feedback * Fixing build * Fix unit test * Fix unit tests * PR review * Fixing JsonSerializationContext issues * Adding statuscode 405 * Update src/Http/Http.Extensions/src/ProblemDetailsDefaultWriter.cs Co-authored-by: Brennan <[email protected]> * PR review * Apply suggestions from code review Co-authored-by: Stephen Halter <[email protected]> * Fixing bad merge * Fix bad merge * Adding analysis.NextMiddlewareName * PR review * Changing to CanWrite/WriteAsync Co-authored-by: Brennan <[email protected]> Co-authored-by: Stephen Halter <[email protected]>
1 parent ca77aff commit ac67d32

File tree

69 files changed

+1968
-514
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+1968
-514
lines changed

src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
2626
<Compile Include="$(SharedSourceRoot)PropertyHelper\**\*.cs" />
2727
<Compile Include="$(SharedSourceRoot)\UrlDecoder\UrlDecoder.cs" Link="UrlDecoder.cs" />
2828
<Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" />
29+
<Compile Include="$(SharedSourceRoot)ProblemDetails\HttpValidationProblemDetailsJsonConverter.cs" LinkBase="ProblemDetails\Converters" />
30+
<Compile Include="$(SharedSourceRoot)ProblemDetails\ProblemDetailsJsonConverter.cs" LinkBase="ProblemDetails\Converters" />
2931
</ItemGroup>
3032

3133
<ItemGroup>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http;
5+
6+
/// <summary>
7+
/// Defines a type that provide functionality to
8+
/// create a <see cref="Mvc.ProblemDetails"/> response.
9+
/// </summary>
10+
public interface IProblemDetailsService
11+
{
12+
/// <summary>
13+
/// Try to write a <see cref="Mvc.ProblemDetails"/> response to the current context,
14+
/// using the registered <see cref="IProblemDetailsWriter"/> services.
15+
/// </summary>
16+
/// <param name="context">The <see cref="ProblemDetailsContext"/> associated with the current request/response.</param>
17+
/// <remarks>The <see cref="IProblemDetailsWriter"/> registered services
18+
/// are processed in sequence and the processing is completed when:
19+
/// <list type="bullet">One of them reports that the response was written successfully, or.</list>
20+
/// <list type="bullet">All <see cref="IProblemDetailsWriter"/> were executed and none of them was able to write the response successfully.</list>
21+
/// </remarks>
22+
ValueTask WriteAsync(ProblemDetailsContext context);
23+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http;
5+
6+
/// <summary>
7+
/// Defines a type that write a <see cref="Mvc.ProblemDetails"/>
8+
/// payload to the current <see cref="HttpContext.Response"/>.
9+
/// </summary>
10+
public interface IProblemDetailsWriter
11+
{
12+
/// <summary>
13+
/// Write a <see cref="Mvc.ProblemDetails"/> response to the current context
14+
/// </summary>
15+
/// <param name="context">The <see cref="ProblemDetailsContext"/> associated with the current request/response.</param>
16+
ValueTask WriteAsync(ProblemDetailsContext context);
17+
18+
/// <summary>
19+
/// Determines whether this instance can write a <see cref="Mvc.ProblemDetails"/> to the current context.
20+
/// </summary>
21+
/// <param name="context">The <see cref="ProblemDetailsContext"/> associated with the current request/response.</param>
22+
/// <returns>Flag that indicates if that the writer can write to the current <see cref="ProblemDetailsContext"/>.</returns>
23+
bool CanWrite(ProblemDetailsContext context);
24+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Mvc;
5+
6+
namespace Microsoft.AspNetCore.Http;
7+
8+
/// <summary>
9+
/// Represent the current problem details context for the request.
10+
/// </summary>
11+
public sealed class ProblemDetailsContext
12+
{
13+
private ProblemDetails? _problemDetails;
14+
15+
/// <summary>
16+
/// The <see cref="HttpContext"/> associated with the current request being processed by the filter.
17+
/// </summary>
18+
public required HttpContext HttpContext { get; init; }
19+
20+
/// <summary>
21+
/// A collection of additional arbitrary metadata associated with the current request endpoint.
22+
/// </summary>
23+
public EndpointMetadataCollection? AdditionalMetadata { get; init; }
24+
25+
/// <summary>
26+
/// An instance of <see cref="ProblemDetails"/> that will be
27+
/// used during the response payload generation.
28+
/// </summary>
29+
public ProblemDetails ProblemDetails
30+
{
31+
get => _problemDetails ??= new ProblemDetails();
32+
init => _problemDetails = value;
33+
}
34+
}

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Microsoft.AspNetCore.Http.EndpointFilterInvocationContext
2121
Microsoft.AspNetCore.Http.EndpointFilterInvocationContext.EndpointFilterInvocationContext() -> void
2222
Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object!
2323
Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata<T>() -> T!
24+
Microsoft.AspNetCore.Http.HttpValidationProblemDetails
25+
Microsoft.AspNetCore.Http.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary<string!, string![]!>!
26+
Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void
27+
Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary<string!, string![]!>! errors) -> void
2428
Microsoft.AspNetCore.Http.IBindableFromHttpContext<TSelf>
2529
Microsoft.AspNetCore.Http.IBindableFromHttpContext<TSelf>.BindAsync(Microsoft.AspNetCore.Http.HttpContext! context, System.Reflection.ParameterInfo! parameter) -> System.Threading.Tasks.ValueTask<TSelf?>
2630
Microsoft.AspNetCore.Http.IContentTypeHttpResult
@@ -30,8 +34,13 @@ Microsoft.AspNetCore.Http.IEndpointFilter.InvokeAsync(Microsoft.AspNetCore.Http.
3034
Microsoft.AspNetCore.Http.IFileHttpResult
3135
Microsoft.AspNetCore.Http.IFileHttpResult.ContentType.get -> string?
3236
Microsoft.AspNetCore.Http.IFileHttpResult.FileDownloadName.get -> string?
37+
Microsoft.AspNetCore.Http.IProblemDetailsService
38+
Microsoft.AspNetCore.Http.IProblemDetailsService.WriteAsync(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> System.Threading.Tasks.ValueTask
39+
Microsoft.AspNetCore.Http.IProblemDetailsWriter
3340
Microsoft.AspNetCore.Http.INestedHttpResult
3441
Microsoft.AspNetCore.Http.INestedHttpResult.Result.get -> Microsoft.AspNetCore.Http.IResult!
42+
Microsoft.AspNetCore.Http.IProblemDetailsWriter.CanWrite(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> bool
43+
Microsoft.AspNetCore.Http.IProblemDetailsWriter.WriteAsync(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> System.Threading.Tasks.ValueTask
3544
Microsoft.AspNetCore.Http.IStatusCodeHttpResult
3645
Microsoft.AspNetCore.Http.IStatusCodeHttpResult.StatusCode.get -> int?
3746
Microsoft.AspNetCore.Http.IValueHttpResult
@@ -42,6 +51,14 @@ Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
4251
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string?
4352
Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata
4453
Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata.MaxRequestBodySize.get -> long?
54+
Microsoft.AspNetCore.Http.ProblemDetailsContext
55+
Microsoft.AspNetCore.Http.ProblemDetailsContext.AdditionalMetadata.get -> Microsoft.AspNetCore.Http.EndpointMetadataCollection?
56+
Microsoft.AspNetCore.Http.ProblemDetailsContext.AdditionalMetadata.init -> void
57+
Microsoft.AspNetCore.Http.ProblemDetailsContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!
58+
Microsoft.AspNetCore.Http.ProblemDetailsContext.HttpContext.init -> void
59+
Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetails.get -> Microsoft.AspNetCore.Mvc.ProblemDetails!
60+
Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetails.init -> void
61+
Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetailsContext() -> void
4562
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(Microsoft.AspNetCore.Routing.RouteValueDictionary? dictionary) -> void
4663
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, object?>>? values) -> void
4764
Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string!, string?>>? values) -> void
@@ -51,6 +68,19 @@ Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata
5168
Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata.Description.get -> string!
5269
Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata
5370
Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata.Summary.get -> string!
71+
Microsoft.AspNetCore.Mvc.ProblemDetails
72+
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string?
73+
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void
74+
Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary<string!, object?>!
75+
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string?
76+
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void
77+
Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void
78+
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int?
79+
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void
80+
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string?
81+
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void
82+
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string?
83+
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void
5484
override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.Arguments.get -> System.Collections.Generic.IList<object?>!
5585
override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.GetArgument<T>(int index) -> T
5686
override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!

src/Http/Http.Extensions/test/HttpValidationProblemDetailsJsonConverterTest.cs renamed to src/Http/Http.Abstractions/test/HttpValidationProblemDetailsJsonConverterTest.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
using System.Text.Json;
66
using Microsoft.AspNetCore.Http.Json;
77

8-
namespace Microsoft.AspNetCore.Http.Extensions;
8+
namespace Microsoft.AspNetCore.Http.Abstractions.Tests;
99

1010
public class HttpValidationProblemDetailsJsonConverterTest
1111
{
@@ -40,7 +40,7 @@ public void Read_Works()
4040
kvp =>
4141
{
4242
Assert.Equal("traceId", kvp.Key);
43-
Assert.Equal(traceId, kvp.Value.ToString());
43+
Assert.Equal(traceId, kvp.Value?.ToString());
4444
});
4545
Assert.Collection(
4646
problemDetails.Errors.OrderBy(kvp => kvp.Key),
@@ -81,7 +81,7 @@ public void Read_WithSomeMissingValues_Works()
8181
kvp =>
8282
{
8383
Assert.Equal("traceId", kvp.Key);
84-
Assert.Equal(traceId, kvp.Value.ToString());
84+
Assert.Equal(traceId, kvp.Value?.ToString());
8585
});
8686
Assert.Collection(
8787
problemDetails.Errors.OrderBy(kvp => kvp.Key),
@@ -111,15 +111,16 @@ public void ReadUsingJsonSerializerWorks()
111111
// Act
112112
var problemDetails = JsonSerializer.Deserialize<HttpValidationProblemDetails>(json, JsonSerializerOptions);
113113

114-
Assert.Equal(type, problemDetails.Type);
114+
Assert.NotNull(problemDetails);
115+
Assert.Equal(type, problemDetails!.Type);
115116
Assert.Equal(title, problemDetails.Title);
116117
Assert.Equal(status, problemDetails.Status);
117118
Assert.Collection(
118119
problemDetails.Extensions,
119120
kvp =>
120121
{
121122
Assert.Equal("traceId", kvp.Key);
122-
Assert.Equal(traceId, kvp.Value.ToString());
123+
Assert.Equal(traceId, kvp.Value?.ToString());
123124
});
124125
Assert.Collection(
125126
problemDetails.Errors.OrderBy(kvp => kvp.Key),

src/Http/Http.Extensions/test/ProblemDetailsJsonConverterTest.cs renamed to src/Http/Http.Abstractions/test/ProblemDetailsJsonConverterTest.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
using Microsoft.AspNetCore.Http.Json;
77
using Microsoft.AspNetCore.Mvc;
88

9-
namespace Microsoft.AspNetCore.Http.Extensions;
9+
namespace Microsoft.AspNetCore.Http.Abstractions.Tests;
1010

1111
public class ProblemDetailsJsonConverterTest
1212
{
@@ -46,6 +46,7 @@ public void Read_Works()
4646
// Act
4747
var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions);
4848

49+
//Assert
4950
Assert.Equal(type, problemDetails.Type);
5051
Assert.Equal(title, problemDetails.Title);
5152
Assert.Equal(status, problemDetails.Status);
@@ -56,7 +57,7 @@ public void Read_Works()
5657
kvp =>
5758
{
5859
Assert.Equal("traceId", kvp.Key);
59-
Assert.Equal(traceId, kvp.Value.ToString());
60+
Assert.Equal(traceId, kvp.Value?.ToString());
6061
});
6162
}
6263

@@ -75,7 +76,9 @@ public void Read_UsingJsonSerializerWorks()
7576
// Act
7677
var problemDetails = JsonSerializer.Deserialize<ProblemDetails>(json, JsonSerializerOptions);
7778

78-
Assert.Equal(type, problemDetails.Type);
79+
// Assert
80+
Assert.NotNull(problemDetails);
81+
Assert.Equal(type, problemDetails!.Type);
7982
Assert.Equal(title, problemDetails.Title);
8083
Assert.Equal(status, problemDetails.Status);
8184
Assert.Equal(instance, problemDetails.Instance);
@@ -85,7 +88,7 @@ public void Read_UsingJsonSerializerWorks()
8588
kvp =>
8689
{
8790
Assert.Equal("traceId", kvp.Key);
88-
Assert.Equal(traceId, kvp.Value.ToString());
91+
Assert.Equal(traceId, kvp.Value?.ToString());
8992
});
9093
}
9194

@@ -105,6 +108,7 @@ public void Read_WithSomeMissingValues_Works()
105108
// Act
106109
var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions);
107110

111+
// Assert
108112
Assert.Equal(type, problemDetails.Type);
109113
Assert.Equal(title, problemDetails.Title);
110114
Assert.Equal(status, problemDetails.Status);
@@ -113,7 +117,7 @@ public void Read_WithSomeMissingValues_Works()
113117
kvp =>
114118
{
115119
Assert.Equal("traceId", kvp.Key);
116-
Assert.Equal(traceId, kvp.Value.ToString());
120+
Assert.Equal(traceId, kvp.Value?.ToString());
117121
});
118122
}
119123

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using System.Linq;
6+
using System.Text.Json.Serialization;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.Extensions.Options;
9+
using Microsoft.Net.Http.Headers;
10+
11+
namespace Microsoft.AspNetCore.Http;
12+
13+
internal sealed partial class DefaultProblemDetailsWriter : IProblemDetailsWriter
14+
{
15+
private static readonly MediaTypeHeaderValue _jsonMediaType = new("application/json");
16+
private static readonly MediaTypeHeaderValue _problemDetailsJsonMediaType = new("application/problem+json");
17+
private readonly ProblemDetailsOptions _options;
18+
19+
public DefaultProblemDetailsWriter(IOptions<ProblemDetailsOptions> options)
20+
{
21+
_options = options.Value;
22+
}
23+
24+
public bool CanWrite(ProblemDetailsContext context)
25+
{
26+
var httpContext = context.HttpContext;
27+
var acceptHeader = httpContext.Request.Headers.Accept.GetList<MediaTypeHeaderValue>();
28+
29+
if (acceptHeader?.Any(h => _jsonMediaType.IsSubsetOf(h) || _problemDetailsJsonMediaType.IsSubsetOf(h)) == true)
30+
{
31+
return true;
32+
}
33+
34+
return false;
35+
}
36+
37+
[UnconditionalSuppressMessage("Trimming", "IL2026",
38+
Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed and we need to fallback" +
39+
"to reflection-based. The ProblemDetailsConverter is marked as RequiresUnreferencedCode already.")]
40+
public ValueTask WriteAsync(ProblemDetailsContext context)
41+
{
42+
var httpContext = context.HttpContext;
43+
ProblemDetailsDefaults.Apply(context.ProblemDetails, httpContext.Response.StatusCode);
44+
_options.CustomizeProblemDetails?.Invoke(context);
45+
46+
if (context.ProblemDetails.Extensions is { Count: 0 })
47+
{
48+
// We can use the source generation in this case
49+
return new ValueTask(httpContext.Response.WriteAsJsonAsync(
50+
context.ProblemDetails,
51+
ProblemDetailsJsonContext.Default.ProblemDetails,
52+
contentType: "application/problem+json"));
53+
}
54+
55+
return new ValueTask(httpContext.Response.WriteAsJsonAsync(
56+
context.ProblemDetails,
57+
options: null,
58+
contentType: "application/problem+json"));
59+
}
60+
61+
[JsonSerializable(typeof(ProblemDetails))]
62+
internal sealed partial class ProblemDetailsJsonContext : JsonSerializerContext
63+
{ }
64+
}

src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@
1515
<Compile Include="$(SharedSourceRoot)ParameterBindingMethodCache.cs" LinkBase="Shared"/>
1616
<Compile Include="$(SharedSourceRoot)PropertyAsParameterInfo.cs" LinkBase="Shared"/>
1717
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" LinkBase="Shared" />
18-
<Compile Include="$(SharedSourceRoot)ProblemDetailsJsonConverter.cs" LinkBase="Shared"/>
19-
<Compile Include="$(SharedSourceRoot)HttpValidationProblemDetailsJsonConverter.cs" LinkBase="Shared" />
2018
<Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
2119
<Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared"/>
20+
<Compile Include="$(SharedSourceRoot)ProblemDetails\ProblemDetailsDefaults.cs" LinkBase="Shared" />
2221
</ItemGroup>
2322

2423
<ItemGroup>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http;
5+
6+
/// <summary>
7+
/// Options for controlling the behavior of <see cref="IProblemDetailsService.WriteAsync(ProblemDetailsContext)"/>
8+
/// and similar methods.
9+
/// </summary>
10+
public class ProblemDetailsOptions
11+
{
12+
/// <summary>
13+
/// The operation that customizes the current <see cref="Mvc.ProblemDetails"/> instance.
14+
/// </summary>
15+
public Action<ProblemDetailsContext>? CustomizeProblemDetails { get; set; }
16+
}

0 commit comments

Comments
 (0)