Skip to content

Commit 885cbe3

Browse files
authored
Add ProblemDetails helpers to Results (#34206)
* Type-forward ProblemDetails to Http.Extensions * Introduce HttpValidationProblemDetails to support validation details * Remove IResult from StatusCodeResult Fixes #34169
1 parent d8bba72 commit 885cbe3

24 files changed

+652
-252
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Text.Json.Serialization;
7+
using Microsoft.AspNetCore.Mvc;
8+
9+
namespace Microsoft.AspNetCore.Http.Extensions
10+
{
11+
/// <summary>
12+
/// A <see cref="ProblemDetails"/> for validation errors.
13+
/// </summary>
14+
[JsonConverter(typeof(HttpValidationProblemDetailsJsonConverter))]
15+
public class HttpValidationProblemDetails : ProblemDetails
16+
{
17+
/// <summary>
18+
/// Initializes a new instance of <see cref="HttpValidationProblemDetails"/>.
19+
/// </summary>
20+
public HttpValidationProblemDetails()
21+
: this(new Dictionary<string, string[]>(StringComparer.Ordinal))
22+
{
23+
}
24+
25+
/// <summary>
26+
/// Initializes a new instance of <see cref="HttpValidationProblemDetails"/> using the specified <paramref name="errors"/>.
27+
/// </summary>
28+
/// <param name="errors">The validation errors.</param>
29+
public HttpValidationProblemDetails(IDictionary<string, string[]> errors)
30+
: this(new Dictionary<string, string[]>(errors, StringComparer.Ordinal))
31+
{
32+
}
33+
34+
private HttpValidationProblemDetails(Dictionary<string, string[]> errors)
35+
{
36+
Title = "One or more validation errors occurred.";
37+
Errors = errors;
38+
}
39+
40+
/// <summary>
41+
/// Gets the validation errors associated with this instance of <see cref="HttpValidationProblemDetails"/>.
42+
/// </summary>
43+
public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal);
44+
}
45+
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<Description>ASP.NET Core common extension methods for HTTP abstractions, HTTP headers, HTTP request/response, and session state.</Description>
@@ -11,9 +11,11 @@
1111
</PropertyGroup>
1212

1313
<ItemGroup>
14-
<Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" />
15-
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
16-
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" />
14+
<Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" LinkBase="Shared"/>
15+
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" LinkBase="Shared"/>
16+
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" LinkBase="Shared" />
17+
<Compile Include="$(SharedSourceRoot)ProblemDetailsJsonConverter.cs" LinkBase="Shared"/>
18+
<Compile Include="$(SharedSourceRoot)HttpValidationProblemDetailsJsonConverter.cs" LinkBase="Shared" />
1719
</ItemGroup>
1820

1921
<ItemGroup>

src/Mvc/Mvc.Core/src/ProblemDetails.cs renamed to src/Http/Http.Extensions/src/ProblemDetails.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Text.Json.Serialization;
7-
using Microsoft.AspNetCore.Mvc.Infrastructure;
7+
using Microsoft.AspNetCore.Http.Extensions;
88

99
namespace Microsoft.AspNetCore.Mvc
1010
{

src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.GetString(this Microsoft.AspNetCore.Http.ISession session, string key) -> string
9090
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.SetInt32(this Microsoft.AspNetCore.Http.ISession session, string key, int value) -> void
9191
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.SetString(this Microsoft.AspNetCore.Http.ISession session, string key, string value) -> void
92+
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails
93+
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary<string!, string![]!>!
94+
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void
95+
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary<string!, string![]!>! errors) -> void
9296
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Add(string! key, System.Collections.Generic.IEnumerable<string!>! values) -> void
9397
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Add(string! key, string! value) -> void
9498
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.GetEnumerator() -> System.Collections.Generic.IEnumerator<System.Collections.Generic.KeyValuePair<string!, string!>>!
@@ -153,6 +157,19 @@ Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetCookie.get -> System.Collec
153157
Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetCookie.set -> void
154158
Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetList<T>(string! name, System.Collections.Generic.IList<T>? values) -> void
155159
Microsoft.AspNetCore.Http.RequestDelegateFactory
160+
Microsoft.AspNetCore.Mvc.ProblemDetails
161+
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string?
162+
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void
163+
Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary<string!, object?>!
164+
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string?
165+
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void
166+
Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void
167+
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int?
168+
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void
169+
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string?
170+
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void
171+
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string?
172+
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void
156173
override Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Equals(object? obj) -> bool
157174
override Microsoft.AspNetCore.Http.Extensions.QueryBuilder.ToString() -> string!
158175
static Microsoft.AspNetCore.Http.Extensions.HttpRequestMultipartExtensions.GetMultipartBoundary(this Microsoft.AspNetCore.Http.HttpRequest! request) -> string!
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Linq;
5+
using System.Text;
6+
using System.Text.Json;
7+
using Microsoft.AspNetCore.Http.Json;
8+
using Xunit;
9+
10+
namespace Microsoft.AspNetCore.Http.Extensions
11+
{
12+
public class HttpValidationProblemDetailsJsonConverterTest
13+
{
14+
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().SerializerOptions;
15+
16+
[Fact]
17+
public void Read_Works()
18+
{
19+
// Arrange
20+
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
21+
var title = "Not found";
22+
var status = 404;
23+
var detail = "Product not found";
24+
var instance = "http://example.com/products/14";
25+
var traceId = "|37dd3dd5-4a9619f953c40a16.";
26+
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"detail\":\"{detail}\", \"instance\":\"{instance}\",\"traceId\":\"{traceId}\"," +
27+
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";
28+
var converter = new HttpValidationProblemDetailsJsonConverter();
29+
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
30+
reader.Read();
31+
32+
// Act
33+
var problemDetails = converter.Read(ref reader, typeof(HttpValidationProblemDetails), JsonSerializerOptions);
34+
35+
Assert.Equal(type, problemDetails.Type);
36+
Assert.Equal(title, problemDetails.Title);
37+
Assert.Equal(status, problemDetails.Status);
38+
Assert.Equal(instance, problemDetails.Instance);
39+
Assert.Equal(detail, problemDetails.Detail);
40+
Assert.Collection(
41+
problemDetails.Extensions,
42+
kvp =>
43+
{
44+
Assert.Equal("traceId", kvp.Key);
45+
Assert.Equal(traceId, kvp.Value.ToString());
46+
});
47+
Assert.Collection(
48+
problemDetails.Errors.OrderBy(kvp => kvp.Key),
49+
kvp =>
50+
{
51+
Assert.Equal("key0", kvp.Key);
52+
Assert.Equal(new[] { "error0" }, kvp.Value);
53+
},
54+
kvp =>
55+
{
56+
Assert.Equal("key1", kvp.Key);
57+
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
58+
});
59+
}
60+
61+
[Fact]
62+
public void Read_WithSomeMissingValues_Works()
63+
{
64+
// Arrange
65+
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
66+
var title = "Not found";
67+
var status = 404;
68+
var traceId = "|37dd3dd5-4a9619f953c40a16.";
69+
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"traceId\":\"{traceId}\"," +
70+
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";
71+
var converter = new HttpValidationProblemDetailsJsonConverter();
72+
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
73+
reader.Read();
74+
75+
// Act
76+
var problemDetails = converter.Read(ref reader, typeof(HttpValidationProblemDetails), JsonSerializerOptions);
77+
78+
Assert.Equal(type, problemDetails.Type);
79+
Assert.Equal(title, problemDetails.Title);
80+
Assert.Equal(status, problemDetails.Status);
81+
Assert.Collection(
82+
problemDetails.Extensions,
83+
kvp =>
84+
{
85+
Assert.Equal("traceId", kvp.Key);
86+
Assert.Equal(traceId, kvp.Value.ToString());
87+
});
88+
Assert.Collection(
89+
problemDetails.Errors.OrderBy(kvp => kvp.Key),
90+
kvp =>
91+
{
92+
Assert.Equal("key0", kvp.Key);
93+
Assert.Equal(new[] { "error0" }, kvp.Value);
94+
},
95+
kvp =>
96+
{
97+
Assert.Equal("key1", kvp.Key);
98+
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
99+
});
100+
}
101+
102+
[Fact]
103+
public void ReadUsingJsonSerializerWorks()
104+
{
105+
// Arrange
106+
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
107+
var title = "Not found";
108+
var status = 404;
109+
var traceId = "|37dd3dd5-4a9619f953c40a16.";
110+
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"traceId\":\"{traceId}\"," +
111+
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";
112+
113+
// Act
114+
var problemDetails = JsonSerializer.Deserialize<HttpValidationProblemDetails>(json, JsonSerializerOptions);
115+
116+
Assert.Equal(type, problemDetails.Type);
117+
Assert.Equal(title, problemDetails.Title);
118+
Assert.Equal(status, problemDetails.Status);
119+
Assert.Collection(
120+
problemDetails.Extensions,
121+
kvp =>
122+
{
123+
Assert.Equal("traceId", kvp.Key);
124+
Assert.Equal(traceId, kvp.Value.ToString());
125+
});
126+
Assert.Collection(
127+
problemDetails.Errors.OrderBy(kvp => kvp.Key),
128+
kvp =>
129+
{
130+
Assert.Equal("key0", kvp.Key);
131+
Assert.Equal(new[] { "error0" }, kvp.Value);
132+
},
133+
kvp =>
134+
{
135+
Assert.Equal("key1", kvp.Key);
136+
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
137+
});
138+
}
139+
}
140+
}

src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetailsJsonConverterTest.cs renamed to src/Http/Http.Extensions/test/ProblemDetailsJsonConverterTest.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.IO;
55
using System.Text;
66
using System.Text.Json;
7+
using Microsoft.AspNetCore.Http.Json;
8+
using Microsoft.AspNetCore.Mvc;
79
using Xunit;
810

9-
namespace Microsoft.AspNetCore.Mvc.Infrastructure
11+
namespace Microsoft.AspNetCore.Http.Extensions
1012
{
1113
public class ProblemDetailsJsonConverterTest
1214
{
13-
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().JsonSerializerOptions;
15+
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().SerializerOptions;
1416

1517
[Fact]
1618
public void Read_ThrowsIfJsonIsIncomplete()

src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Compile Include="$(SharedSourceRoot)ResponseContentTypeHelper.cs" LinkBase="Shared" />
1717
<Compile Include="$(SharedSourceRoot)ResultsHelpers\*.cs" LinkBase="Shared" />
1818
<Compile Include="$(SharedSourceRoot)RangeHelper\RangeHelper.cs" LinkBase="Shared" />
19+
<Compile Include="$(SharedSourceRoot)ProblemDetailsDefaults.cs" LinkBase="Shared" />
1920
</ItemGroup>
2021

2122
<ItemGroup>

src/Http/Http.Results/src/ObjectResult.cs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Http.Extensions;
6+
using Microsoft.AspNetCore.Mvc;
57
using Microsoft.Extensions.DependencyInjection;
68
using Microsoft.Extensions.Logging;
79

@@ -12,7 +14,15 @@ internal partial class ObjectResult : IResult
1214
/// <summary>
1315
/// Creates a new <see cref="ObjectResult"/> instance with the provided <paramref name="value"/>.
1416
/// </summary>
15-
public ObjectResult(object? value, int statusCode)
17+
public ObjectResult(object? value)
18+
{
19+
Value = value;
20+
}
21+
22+
/// <summary>
23+
/// Creates a new <see cref="ObjectResult"/> instance with the provided <paramref name="value"/>.
24+
/// </summary>
25+
public ObjectResult(object? value, int? statusCode)
1626
{
1727
Value = value;
1828
StatusCode = statusCode;
@@ -24,22 +34,61 @@ public ObjectResult(object? value, int statusCode)
2434
public object? Value { get; }
2535

2636
/// <summary>
27-
/// Gets or sets the HTTP status code.
37+
/// Gets the HTTP status code.
2838
/// </summary>
29-
public int StatusCode { get; }
39+
public int? StatusCode { get; set; }
3040

3141
public Task ExecuteAsync(HttpContext httpContext)
3242
{
3343
var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
3444
var logger = loggerFactory.CreateLogger(GetType());
3545
Log.ObjectResultExecuting(logger, Value);
3646

37-
httpContext.Response.StatusCode = StatusCode;
47+
if (Value is ProblemDetails problemDetails)
48+
{
49+
ApplyProblemDetailsDefaults(problemDetails);
50+
}
51+
52+
if (StatusCode is { } statusCode)
53+
{
54+
httpContext.Response.StatusCode = statusCode;
55+
}
3856

3957
OnFormatting(httpContext);
4058
return httpContext.Response.WriteAsJsonAsync(Value);
4159
}
4260

61+
private void ApplyProblemDetailsDefaults(ProblemDetails problemDetails)
62+
{
63+
// We allow StatusCode to be specified either on ProblemDetails or on the ObjectResult and use it to configure the other.
64+
// This lets users write <c>return Conflict(new Problem("some description"))</c>
65+
// or <c>return Problem("some-problem", 422)</c> and have the response have consistent fields.
66+
if (problemDetails.Status is null)
67+
{
68+
if (StatusCode is not null)
69+
{
70+
problemDetails.Status = StatusCode;
71+
}
72+
else
73+
{
74+
problemDetails.Status = problemDetails is HttpValidationProblemDetails ?
75+
StatusCodes.Status400BadRequest :
76+
StatusCodes.Status500InternalServerError;
77+
}
78+
}
79+
80+
if (StatusCode is null)
81+
{
82+
StatusCode = problemDetails.Status;
83+
}
84+
85+
if (ProblemDetailsDefaults.Defaults.TryGetValue(problemDetails.Status.Value, out var defaults))
86+
{
87+
problemDetails.Title ??= defaults.Title;
88+
problemDetails.Type ??= defaults.Type;
89+
}
90+
}
91+
4392
protected virtual void OnFormatting(HttpContext httpContext)
4493
{
4594
}

src/Http/Http.Results/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, stri
7474
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult!
7575
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult!
7676
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult!
77+
static Microsoft.AspNetCore.Http.Results.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Http.IResult!
7778
static Microsoft.AspNetCore.Http.Results.Redirect(string! url) -> Microsoft.AspNetCore.Http.IResult!
7879
static Microsoft.AspNetCore.Http.Results.RedirectPermanent(string! url) -> Microsoft.AspNetCore.Http.IResult!
7980
static Microsoft.AspNetCore.Http.Results.RedirectPermanentPreserveMethod(string! url) -> Microsoft.AspNetCore.Http.IResult!
@@ -102,3 +103,4 @@ static Microsoft.AspNetCore.Http.Results.StatusCode(int statusCode) -> Microsoft
102103
static Microsoft.AspNetCore.Http.Results.Unauthorized() -> Microsoft.AspNetCore.Http.IResult!
103104
static Microsoft.AspNetCore.Http.Results.UnprocessableEntity() -> Microsoft.AspNetCore.Http.IResult!
104105
static Microsoft.AspNetCore.Http.Results.UnprocessableEntity(object? error) -> Microsoft.AspNetCore.Http.IResult!
106+
static Microsoft.AspNetCore.Http.Results.ValidationProblem(System.Collections.Generic.IDictionary<string!, string![]!>! errors, string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Http.IResult!

0 commit comments

Comments
 (0)