diff --git a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj index dc51c9a2f0af..bcd4d403f322 100644 --- a/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj +++ b/src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj @@ -26,6 +26,8 @@ Microsoft.AspNetCore.Http.HttpResponse + + diff --git a/src/Http/Http.Extensions/src/HttpValidationProblemDetails.cs b/src/Http/Http.Abstractions/src/ProblemDetails/HttpValidationProblemDetails.cs similarity index 100% rename from src/Http/Http.Extensions/src/HttpValidationProblemDetails.cs rename to src/Http/Http.Abstractions/src/ProblemDetails/HttpValidationProblemDetails.cs diff --git a/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsService.cs b/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsService.cs new file mode 100644 index 000000000000..564ee29403ad --- /dev/null +++ b/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsService.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +/// +/// Defines a type that provide functionality to +/// create a response. +/// +public interface IProblemDetailsService +{ + /// + /// Try to write a response to the current context, + /// using the registered services. + /// + /// The associated with the current request/response. + /// The registered services + /// are processed in sequence and the processing is completed when: + /// One of them reports that the response was written successfully, or. + /// All were executed and none of them was able to write the response successfully. + /// + ValueTask WriteAsync(ProblemDetailsContext context); +} diff --git a/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsWriter.cs b/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsWriter.cs new file mode 100644 index 000000000000..852e6b2e5789 --- /dev/null +++ b/src/Http/Http.Abstractions/src/ProblemDetails/IProblemDetailsWriter.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +/// +/// Defines a type that write a +/// payload to the current . +/// +public interface IProblemDetailsWriter +{ + /// + /// Write a response to the current context + /// + /// The associated with the current request/response. + ValueTask WriteAsync(ProblemDetailsContext context); + + /// + /// Determines whether this instance can write a to the current context. + /// + /// The associated with the current request/response. + /// Flag that indicates if that the writer can write to the current . + bool CanWrite(ProblemDetailsContext context); +} diff --git a/src/Http/Http.Extensions/src/ProblemDetails.cs b/src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetails.cs similarity index 100% rename from src/Http/Http.Extensions/src/ProblemDetails.cs rename to src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetails.cs diff --git a/src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetailsContext.cs b/src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetailsContext.cs new file mode 100644 index 000000000000..62058cf8ec36 --- /dev/null +++ b/src/Http/Http.Abstractions/src/ProblemDetails/ProblemDetailsContext.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Represent the current problem details context for the request. +/// +public sealed class ProblemDetailsContext +{ + private ProblemDetails? _problemDetails; + + /// + /// The associated with the current request being processed by the filter. + /// + public required HttpContext HttpContext { get; init; } + + /// + /// A collection of additional arbitrary metadata associated with the current request endpoint. + /// + public EndpointMetadataCollection? AdditionalMetadata { get; init; } + + /// + /// An instance of that will be + /// used during the response payload generation. + /// + public ProblemDetails ProblemDetails + { + get => _problemDetails ??= new ProblemDetails(); + init => _problemDetails = value; + } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 9722a6e3718d..43a78d85ac62 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -21,6 +21,10 @@ Microsoft.AspNetCore.Http.EndpointFilterInvocationContext Microsoft.AspNetCore.Http.EndpointFilterInvocationContext.EndpointFilterInvocationContext() -> void Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! +Microsoft.AspNetCore.Http.HttpValidationProblemDetails +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary! errors) -> void Microsoft.AspNetCore.Http.IBindableFromHttpContext Microsoft.AspNetCore.Http.IBindableFromHttpContext.BindAsync(Microsoft.AspNetCore.Http.HttpContext! context, System.Reflection.ParameterInfo! parameter) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.IContentTypeHttpResult @@ -30,8 +34,13 @@ Microsoft.AspNetCore.Http.IEndpointFilter.InvokeAsync(Microsoft.AspNetCore.Http. Microsoft.AspNetCore.Http.IFileHttpResult Microsoft.AspNetCore.Http.IFileHttpResult.ContentType.get -> string? Microsoft.AspNetCore.Http.IFileHttpResult.FileDownloadName.get -> string? +Microsoft.AspNetCore.Http.IProblemDetailsService +Microsoft.AspNetCore.Http.IProblemDetailsService.WriteAsync(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.Http.IProblemDetailsWriter Microsoft.AspNetCore.Http.INestedHttpResult Microsoft.AspNetCore.Http.INestedHttpResult.Result.get -> Microsoft.AspNetCore.Http.IResult! +Microsoft.AspNetCore.Http.IProblemDetailsWriter.CanWrite(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> bool +Microsoft.AspNetCore.Http.IProblemDetailsWriter.WriteAsync(Microsoft.AspNetCore.Http.ProblemDetailsContext! context) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.IStatusCodeHttpResult Microsoft.AspNetCore.Http.IStatusCodeHttpResult.StatusCode.get -> int? Microsoft.AspNetCore.Http.IValueHttpResult @@ -42,6 +51,14 @@ Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata.MaxRequestBodySize.get -> long? +Microsoft.AspNetCore.Http.ProblemDetailsContext +Microsoft.AspNetCore.Http.ProblemDetailsContext.AdditionalMetadata.get -> Microsoft.AspNetCore.Http.EndpointMetadataCollection? +Microsoft.AspNetCore.Http.ProblemDetailsContext.AdditionalMetadata.init -> void +Microsoft.AspNetCore.Http.ProblemDetailsContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.Http.ProblemDetailsContext.HttpContext.init -> void +Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetails.get -> Microsoft.AspNetCore.Mvc.ProblemDetails! +Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetails.init -> void +Microsoft.AspNetCore.Http.ProblemDetailsContext.ProblemDetailsContext() -> void Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(Microsoft.AspNetCore.Routing.RouteValueDictionary? dictionary) -> void Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable>? values) -> void Microsoft.AspNetCore.Routing.RouteValueDictionary.RouteValueDictionary(System.Collections.Generic.IEnumerable>? values) -> void @@ -51,6 +68,19 @@ Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata.Description.get -> string! Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata.Summary.get -> string! +Microsoft.AspNetCore.Mvc.ProblemDetails +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string? +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void +Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string? +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void +Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int? +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string? +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string? +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.Arguments.get -> System.Collections.Generic.IList! override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.GetArgument(int index) -> T override Microsoft.AspNetCore.Http.DefaultEndpointFilterInvocationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! diff --git a/src/Http/Http.Extensions/test/HttpValidationProblemDetailsJsonConverterTest.cs b/src/Http/Http.Abstractions/test/HttpValidationProblemDetailsJsonConverterTest.cs similarity index 93% rename from src/Http/Http.Extensions/test/HttpValidationProblemDetailsJsonConverterTest.cs rename to src/Http/Http.Abstractions/test/HttpValidationProblemDetailsJsonConverterTest.cs index ea8073ce6867..ab4408dd7561 100644 --- a/src/Http/Http.Extensions/test/HttpValidationProblemDetailsJsonConverterTest.cs +++ b/src/Http/Http.Abstractions/test/HttpValidationProblemDetailsJsonConverterTest.cs @@ -5,7 +5,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http.Json; -namespace Microsoft.AspNetCore.Http.Extensions; +namespace Microsoft.AspNetCore.Http.Abstractions.Tests; public class HttpValidationProblemDetailsJsonConverterTest { @@ -40,7 +40,7 @@ public void Read_Works() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); Assert.Collection( problemDetails.Errors.OrderBy(kvp => kvp.Key), @@ -81,7 +81,7 @@ public void Read_WithSomeMissingValues_Works() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); Assert.Collection( problemDetails.Errors.OrderBy(kvp => kvp.Key), @@ -111,7 +111,8 @@ public void ReadUsingJsonSerializerWorks() // Act var problemDetails = JsonSerializer.Deserialize(json, JsonSerializerOptions); - Assert.Equal(type, problemDetails.Type); + Assert.NotNull(problemDetails); + Assert.Equal(type, problemDetails!.Type); Assert.Equal(title, problemDetails.Title); Assert.Equal(status, problemDetails.Status); Assert.Collection( @@ -119,7 +120,7 @@ public void ReadUsingJsonSerializerWorks() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); Assert.Collection( problemDetails.Errors.OrderBy(kvp => kvp.Key), diff --git a/src/Http/Http.Extensions/test/ProblemDetailsJsonConverterTest.cs b/src/Http/Http.Abstractions/test/ProblemDetailsJsonConverterTest.cs similarity index 94% rename from src/Http/Http.Extensions/test/ProblemDetailsJsonConverterTest.cs rename to src/Http/Http.Abstractions/test/ProblemDetailsJsonConverterTest.cs index 6250ea43d7d3..995655ba2947 100644 --- a/src/Http/Http.Extensions/test/ProblemDetailsJsonConverterTest.cs +++ b/src/Http/Http.Abstractions/test/ProblemDetailsJsonConverterTest.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Mvc; -namespace Microsoft.AspNetCore.Http.Extensions; +namespace Microsoft.AspNetCore.Http.Abstractions.Tests; public class ProblemDetailsJsonConverterTest { @@ -46,6 +46,7 @@ public void Read_Works() // Act var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions); + //Assert Assert.Equal(type, problemDetails.Type); Assert.Equal(title, problemDetails.Title); Assert.Equal(status, problemDetails.Status); @@ -56,7 +57,7 @@ public void Read_Works() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); } @@ -75,7 +76,9 @@ public void Read_UsingJsonSerializerWorks() // Act var problemDetails = JsonSerializer.Deserialize(json, JsonSerializerOptions); - Assert.Equal(type, problemDetails.Type); + // Assert + Assert.NotNull(problemDetails); + Assert.Equal(type, problemDetails!.Type); Assert.Equal(title, problemDetails.Title); Assert.Equal(status, problemDetails.Status); Assert.Equal(instance, problemDetails.Instance); @@ -85,7 +88,7 @@ public void Read_UsingJsonSerializerWorks() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); } @@ -105,6 +108,7 @@ public void Read_WithSomeMissingValues_Works() // Act var problemDetails = converter.Read(ref reader, typeof(ProblemDetails), JsonSerializerOptions); + // Assert Assert.Equal(type, problemDetails.Type); Assert.Equal(title, problemDetails.Title); Assert.Equal(status, problemDetails.Status); @@ -113,7 +117,7 @@ public void Read_WithSomeMissingValues_Works() kvp => { Assert.Equal("traceId", kvp.Key); - Assert.Equal(traceId, kvp.Value.ToString()); + Assert.Equal(traceId, kvp.Value?.ToString()); }); } diff --git a/src/Http/Http.Extensions/src/DefaultProblemDetailsWriter.cs b/src/Http/Http.Extensions/src/DefaultProblemDetailsWriter.cs new file mode 100644 index 000000000000..2bedccd8254a --- /dev/null +++ b/src/Http/Http.Extensions/src/DefaultProblemDetailsWriter.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Http; + +internal sealed partial class DefaultProblemDetailsWriter : IProblemDetailsWriter +{ + private static readonly MediaTypeHeaderValue _jsonMediaType = new("application/json"); + private static readonly MediaTypeHeaderValue _problemDetailsJsonMediaType = new("application/problem+json"); + private readonly ProblemDetailsOptions _options; + + public DefaultProblemDetailsWriter(IOptions options) + { + _options = options.Value; + } + + public bool CanWrite(ProblemDetailsContext context) + { + var httpContext = context.HttpContext; + var acceptHeader = httpContext.Request.Headers.Accept.GetList(); + + if (acceptHeader?.Any(h => _jsonMediaType.IsSubsetOf(h) || _problemDetailsJsonMediaType.IsSubsetOf(h)) == true) + { + return true; + } + + return false; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "JSON serialization of ProblemDetails.Extensions might require types that cannot be statically analyzed and we need to fallback" + + "to reflection-based. The ProblemDetailsConverter is marked as RequiresUnreferencedCode already.")] + public ValueTask WriteAsync(ProblemDetailsContext context) + { + var httpContext = context.HttpContext; + ProblemDetailsDefaults.Apply(context.ProblemDetails, httpContext.Response.StatusCode); + _options.CustomizeProblemDetails?.Invoke(context); + + if (context.ProblemDetails.Extensions is { Count: 0 }) + { + // We can use the source generation in this case + return new ValueTask(httpContext.Response.WriteAsJsonAsync( + context.ProblemDetails, + ProblemDetailsJsonContext.Default.ProblemDetails, + contentType: "application/problem+json")); + } + + return new ValueTask(httpContext.Response.WriteAsJsonAsync( + context.ProblemDetails, + options: null, + contentType: "application/problem+json")); + } + + [JsonSerializable(typeof(ProblemDetails))] + internal sealed partial class ProblemDetailsJsonContext : JsonSerializerContext + { } +} diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index ba4e362f4db3..1d181f224b21 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -15,10 +15,9 @@ - - + diff --git a/src/Http/Http.Extensions/src/ProblemDetailsOptions.cs b/src/Http/Http.Extensions/src/ProblemDetailsOptions.cs new file mode 100644 index 000000000000..13fd59d82d47 --- /dev/null +++ b/src/Http/Http.Extensions/src/ProblemDetailsOptions.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +/// +/// Options for controlling the behavior of +/// and similar methods. +/// +public class ProblemDetailsOptions +{ + /// + /// The operation that customizes the current instance. + /// + public Action? CustomizeProblemDetails { get; set; } +} diff --git a/src/Http/Http.Extensions/src/ProblemDetailsService.cs b/src/Http/Http.Extensions/src/ProblemDetailsService.cs new file mode 100644 index 000000000000..68002af77b96 --- /dev/null +++ b/src/Http/Http.Extensions/src/ProblemDetailsService.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +using System.Linq; + +internal sealed class ProblemDetailsService : IProblemDetailsService +{ + private readonly IProblemDetailsWriter[] _writers; + + public ProblemDetailsService( + IEnumerable writers) + { + _writers = writers.ToArray(); + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(context.ProblemDetails); + ArgumentNullException.ThrowIfNull(context.HttpContext); + + if (context.HttpContext.Response.HasStarted || + context.HttpContext.Response.StatusCode < 400 || + _writers.Length == 0) + { + return ValueTask.CompletedTask; + } + + IProblemDetailsWriter? selectedWriter = null; + + if (_writers.Length == 1) + { + selectedWriter = _writers[0]; + + return selectedWriter.CanWrite(context) ? + selectedWriter.WriteAsync(context) : + ValueTask.CompletedTask; + } + + for (var i = 0; i < _writers.Length; i++) + { + if (_writers[i].CanWrite(context)) + { + selectedWriter = _writers[i]; + break; + } + } + + if (selectedWriter != null) + { + return selectedWriter.WriteAsync(context); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Http/Http.Extensions/src/ProblemDetailsServiceCollectionExtensions.cs b/src/Http/Http.Extensions/src/ProblemDetailsServiceCollectionExtensions.cs new file mode 100644 index 000000000000..c8508a3fa668 --- /dev/null +++ b/src/Http/Http.Extensions/src/ProblemDetailsServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Contains extension methods to . +/// +public static class ProblemDetailsServiceCollectionExtensions +{ + /// + /// Adds services required for creation of for failed requests. + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddProblemDetails(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + return services.AddProblemDetails(configure: null); + } + + /// + /// Adds services required for creation of for failed requests. + /// + /// The to add the services to. + /// The to configure the services with. + /// The so that additional calls can be chained. + public static IServiceCollection AddProblemDetails( + this IServiceCollection services, + Action? configure) + { + ArgumentNullException.ThrowIfNull(services); + + // Adding default services; + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + if (configure != null) + { + services.Configure(configure); + } + + return services; + } +} diff --git a/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs b/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs index f7cd6ab89feb..2122b7773ae1 100644 --- a/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs +++ b/src/Http/Http.Extensions/src/Properties/AssemblyInfo.cs @@ -3,4 +3,7 @@ using System.Runtime.CompilerServices; +[assembly: TypeForwardedTo(typeof(Microsoft.AspNetCore.Mvc.ProblemDetails))] +[assembly: TypeForwardedTo(typeof(Microsoft.AspNetCore.Http.HttpValidationProblemDetails))] + [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Http.Extensions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index 00c713fbc424..4a1707193af5 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1,4 +1,12 @@ #nullable enable +*REMOVED*Microsoft.AspNetCore.Http.HttpValidationProblemDetails +*REMOVED*Microsoft.AspNetCore.Http.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary! +*REMOVED*Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void +*REMOVED*Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary! errors) -> void +Microsoft.AspNetCore.Http.HttpValidationProblemDetails (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Http.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary! errors) -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.ApplicationServices.get -> System.IServiceProvider! Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.EndpointMetadataContext(System.Reflection.MethodInfo! method, System.Collections.Generic.IList! endpointMetadata, System.IServiceProvider! applicationServices) -> void @@ -13,6 +21,37 @@ Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider.PopulateMetadata(Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext! context) -> void Microsoft.AspNetCore.Http.Metadata.IEndpointParameterMetadataProvider Microsoft.AspNetCore.Http.Metadata.IEndpointParameterMetadataProvider.PopulateMetadata(Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext! parameterContext) -> void +Microsoft.AspNetCore.Http.ProblemDetailsOptions +Microsoft.AspNetCore.Http.ProblemDetailsOptions.CustomizeProblemDetails.get -> System.Action? +Microsoft.AspNetCore.Http.ProblemDetailsOptions.CustomizeProblemDetails.set -> void +Microsoft.AspNetCore.Http.ProblemDetailsOptions.ProblemDetailsOptions() -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string? +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary! +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string? +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int? +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string? +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string? +*REMOVED*Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void +Microsoft.AspNetCore.Mvc.ProblemDetails (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.Extensions.DependencyInjection.ProblemDetailsServiceCollectionExtensions Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.EndpointFilterFactories.get -> System.Collections.Generic.IReadOnlyList!>? Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.EndpointFilterFactories.init -> void Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.EndpointMetadata.get -> System.Collections.Generic.IList? @@ -22,6 +61,8 @@ static Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(thi static Microsoft.AspNetCore.Http.HttpRequestJsonExtensions.ReadFromJsonAsync(this Microsoft.AspNetCore.Http.HttpRequest! request, System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static Microsoft.AspNetCore.Http.HttpResponseJsonExtensions.WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse! response, object? value, System.Type! type, System.Text.Json.Serialization.JsonSerializerContext! context, string? contentType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Microsoft.AspNetCore.Http.HttpResponseJsonExtensions.WriteAsJsonAsync(this Microsoft.AspNetCore.Http.HttpResponse! response, TValue value, System.Text.Json.Serialization.Metadata.JsonTypeInfo! jsonTypeInfo, string? contentType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.Extensions.DependencyInjection.ProblemDetailsServiceCollectionExtensions.AddProblemDetails(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.ProblemDetailsServiceCollectionExtensions.AddProblemDetails(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Microsoft.AspNetCore.Http.EndpointDescriptionAttribute Microsoft.AspNetCore.Http.EndpointDescriptionAttribute.EndpointDescriptionAttribute(string! description) -> void diff --git a/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs b/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs new file mode 100644 index 000000000000..997641d42f44 --- /dev/null +++ b/src/Http/Http.Extensions/test/ProblemDetailsDefaultWriterTest.cs @@ -0,0 +1,229 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Http.Extensions.Tests; + +public class DefaultProblemDetailsWriterTest +{ + [Fact] + public async Task WriteAsync_Works() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream); + var expectedProblem = new ProblemDetails() + { + Detail = "Custom Bad Request", + Instance = "Custom Bad Request", + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1-custom", + Title = "Custom Bad Request", + }; + var problemDetailsContext = new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = expectedProblem + }; + + //Act + await writer.WriteAsync(problemDetailsContext); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Equal(expectedProblem.Status, problemDetails.Status); + Assert.Equal(expectedProblem.Type, problemDetails.Type); + Assert.Equal(expectedProblem.Title, problemDetails.Title); + Assert.Equal(expectedProblem.Detail, problemDetails.Detail); + Assert.Equal(expectedProblem.Instance, problemDetails.Instance); + } + + [Fact] + public async Task WriteAsync_AddExtensions() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream); + var expectedProblem = new ProblemDetails(); + expectedProblem.Extensions["Extension1"] = "Extension1-Value"; + expectedProblem.Extensions["Extension2"] = "Extension2-Value"; + + var problemDetailsContext = new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = expectedProblem + }; + + //Act + await writer.WriteAsync(problemDetailsContext); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Collection(problemDetails.Extensions, + (extension) => + { + Assert.Equal("Extension1", extension.Key); + Assert.Equal("Extension1-Value", extension.Value.ToString()); + }, + (extension) => + { + Assert.Equal("Extension2", extension.Key); + Assert.Equal("Extension2-Value", extension.Value.ToString()); + }); + } + + [Fact] + public async Task WriteAsync_Applies_Defaults() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, StatusCodes.Status500InternalServerError); + + //Act + await writer.WriteAsync(new ProblemDetailsContext() { HttpContext = context }); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Equal(StatusCodes.Status500InternalServerError, problemDetails.Status); + Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.6.1", problemDetails.Type); + Assert.Equal("An error occurred while processing your request.", problemDetails.Title); + } + + [Fact] + public async Task WriteAsync_Applies_CustomConfiguration() + { + // Arrange + var options = new ProblemDetailsOptions() + { + CustomizeProblemDetails = (context) => + { + context.ProblemDetails.Status = StatusCodes.Status406NotAcceptable; + context.ProblemDetails.Title = "Custom Title"; + context.ProblemDetails.Extensions["new-extension"] = new { TraceId = Guid.NewGuid() }; + } + }; + var writer = GetWriter(options); + var stream = new MemoryStream(); + var context = CreateContext(stream, StatusCodes.Status500InternalServerError); + + //Act + await writer.WriteAsync(new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = { Status = StatusCodes.Status400BadRequest } + }); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Equal(StatusCodes.Status406NotAcceptable, problemDetails.Status); + Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type); + Assert.Equal("Custom Title", problemDetails.Title); + Assert.Contains("new-extension", problemDetails.Extensions); + } + + [Fact] + public async Task WriteAsync_UsesStatusCode_FromProblemDetails_WhenSpecified() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, StatusCodes.Status500InternalServerError); + + //Act + await writer.WriteAsync(new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = { Status = StatusCodes.Status400BadRequest } + }); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Equal(StatusCodes.Status400BadRequest, problemDetails.Status); + Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type); + Assert.Equal("Bad Request", problemDetails.Title); + } + + [Theory] + [InlineData("*/*")] + [InlineData("application/*")] + [InlineData("application/json")] + [InlineData("application/problem+json")] + public void CanWrite_ReturnsTrue_WhenJsonAccepted(string contentType) + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, contentType: contentType); + + //Act + var result = writer.CanWrite(new() { HttpContext = context }); + + //Assert + Assert.True(result); + } + + [Theory] + [InlineData("application/xml")] + [InlineData("application/problem+xml")] + public void CanWrite_ReturnsFalse_WhenJsonNotAccepted(string contentType) + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, contentType: contentType); + + //Act + var result = writer.CanWrite(new() { HttpContext = context }); + + //Assert + Assert.False(result); + } + + private static HttpContext CreateContext( + Stream body, + int statusCode = StatusCodes.Status400BadRequest, + string contentType = "application/json") + { + var context = new DefaultHttpContext() + { + Response = { Body = body, StatusCode = statusCode }, + RequestServices = CreateServices() + }; + context.Request.Headers.Accept = contentType; + return context; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddTransient(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddSingleton(NullLoggerFactory.Instance); + + return services.BuildServiceProvider(); + } + + private static DefaultProblemDetailsWriter GetWriter(ProblemDetailsOptions options = null) + { + options ??= new ProblemDetailsOptions(); + return new DefaultProblemDetailsWriter(Options.Create(options)); + } +} diff --git a/src/Http/Http.Extensions/test/ProblemDetailsServiceCollectionExtensionsTest.cs b/src/Http/Http.Extensions/test/ProblemDetailsServiceCollectionExtensionsTest.cs new file mode 100644 index 000000000000..0760838e00fd --- /dev/null +++ b/src/Http/Http.Extensions/test/ProblemDetailsServiceCollectionExtensionsTest.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; + +namespace Microsoft.AspNetCore.Http.Extensions.Tests; + +public class ProblemDetailsServiceCollectionExtensionsTest +{ + [Fact] + public void AddProblemDetails_AddsNeededServices() + { + // Arrange + var collection = new ServiceCollection(); + + // Act + collection.AddProblemDetails(); + + // Assert + Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsService) && sd.ImplementationType == typeof(ProblemDetailsService)); + Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsWriter) && sd.ImplementationType == typeof(DefaultProblemDetailsWriter)); + } + + [Fact] + public void AddProblemDetails_AllowMultipleWritersRegistration() + { + // Arrange + var collection = new ServiceCollection(); + var expectedCount = 2; + var mockWriter = Mock.Of(); + collection.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IProblemDetailsWriter), mockWriter)); + + // Act + collection.AddProblemDetails(); + + // Assert + var serviceDescriptors = collection.Where(serviceDescriptor => serviceDescriptor.ServiceType == typeof(IProblemDetailsWriter)); + Assert.True( + (expectedCount == serviceDescriptors.Count()), + $"Expected service type '{typeof(IProblemDetailsWriter)}' to be registered {expectedCount}" + + $" time(s) but was actually registered {serviceDescriptors.Count()} time(s)."); + } + + [Fact] + public void AddProblemDetails_KeepCustomRegisteredService() + { + // Arrange + var collection = new ServiceCollection(); + var customService = Mock.Of(); + collection.AddSingleton(typeof(IProblemDetailsService), customService); + + // Act + collection.AddProblemDetails(); + + // Assert + var service = Assert.Single(collection, (sd) => sd.ServiceType == typeof(IProblemDetailsService)); + Assert.Same(customService, service.ImplementationInstance); + } +} diff --git a/src/Http/Http.Extensions/test/ProblemDetailsServiceTest.cs b/src/Http/Http.Extensions/test/ProblemDetailsServiceTest.cs new file mode 100644 index 000000000000..66abd0de36a3 --- /dev/null +++ b/src/Http/Http.Extensions/test/ProblemDetailsServiceTest.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Microsoft.AspNetCore.Http.Extensions.Tests; + +public class ProblemDetailsServiceTest +{ + [Fact] + public async Task WriteAsync_Skip_NextWriters_WhenResponseAlreadyStarted() + { + // Arrange + var service = CreateService( + writers: new List + { + new MetadataBasedWriter("FirstWriter", canWrite: false), + new MetadataBasedWriter("SecondWriter"), + new MetadataBasedWriter("FirstWriter"), + }); + + var metadata = new EndpointMetadataCollection(new SampleMetadata() { ContentType = "application/problem+json" }); + var stream = new MemoryStream(); + var context = new DefaultHttpContext() + { + Response = { Body = stream, StatusCode = StatusCodes.Status400BadRequest }, + }; + + // Act + await service.WriteAsync(new() { HttpContext = context, AdditionalMetadata = metadata }); + + // Assert + Assert.Equal("\"SecondWriter\"", Encoding.UTF8.GetString(stream.ToArray())); + } + + [Fact] + public async Task WriteAsync_Skip_WhenNoWriterRegistered() + { + // Arrange + var service = CreateService(); + var stream = new MemoryStream(); + var context = new DefaultHttpContext() + { + Response = { Body = stream, StatusCode = StatusCodes.Status400BadRequest }, + }; + + // Act + await service.WriteAsync(new() { HttpContext = context }); + + // Assert + Assert.Equal(string.Empty, Encoding.UTF8.GetString(stream.ToArray())); + } + + [Fact] + public async Task WriteAsync_Skip_WhenNoWriterCanWrite() + { + // Arrange + var service = CreateService( + writers: new List { new MetadataBasedWriter() }); + var stream = new MemoryStream(); + var context = new DefaultHttpContext() + { + Response = { Body = stream, StatusCode = StatusCodes.Status400BadRequest }, + }; + + // Act + await service.WriteAsync(new() { HttpContext = context }); + + // Assert + Assert.Equal(string.Empty, Encoding.UTF8.GetString(stream.ToArray())); + } + + [Theory] + [InlineData(StatusCodes.Status100Continue)] + [InlineData(StatusCodes.Status200OK)] + [InlineData(StatusCodes.Status300MultipleChoices)] + [InlineData(399)] + public async Task WriteAsync_Skip_WhenSuccessStatusCode(int statusCode) + { + // Arrange + var service = CreateService( + writers: new List { new MetadataBasedWriter() }); + var stream = new MemoryStream(); + var context = new DefaultHttpContext() + { + Response = { Body = stream, StatusCode = statusCode }, + }; + var metadata = new EndpointMetadataCollection(new SampleMetadata() { ContentType = "application/problem+json" }); + context.SetEndpoint(new Endpoint(context => Task.CompletedTask, metadata, null)); + + // Act + await service.WriteAsync(new() { HttpContext = context }); + + // Assert + Assert.Equal(string.Empty, Encoding.UTF8.GetString(stream.ToArray())); + } + + private static ProblemDetailsService CreateService( + IEnumerable writers = null) + { + writers ??= Array.Empty(); + return new ProblemDetailsService(writers); + } + + private class SampleMetadata + { + public string ContentType { get; set; } + } + + private class MetadataBasedWriter : IProblemDetailsWriter + { + private readonly string _content; + private readonly bool _canWrite; + + public MetadataBasedWriter(string content = "Content", bool canWrite = true) + { + _content = content; + _canWrite = canWrite; + } + + public bool CanWrite(ProblemDetailsContext context) + { + var metadata = context.AdditionalMetadata?.GetMetadata() ?? + context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); + + return metadata != null && _canWrite; + + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + => new(context.HttpContext.Response.WriteAsJsonAsync(_content)); + } +} diff --git a/src/Http/Http.Results/src/HttpResultsHelper.cs b/src/Http/Http.Results/src/HttpResultsHelper.cs index 6f9b2b61e596..c4804353b4e1 100644 --- a/src/Http/Http.Results/src/HttpResultsHelper.cs +++ b/src/Http/Http.Results/src/HttpResultsHelper.cs @@ -3,7 +3,6 @@ using System.Text; using System.Text.Json; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -131,33 +130,7 @@ public static void ApplyProblemDetailsDefaultsIfNeeded(object? value, int? statu { if (value is ProblemDetails problemDetails) { - ApplyProblemDetailsDefaults(problemDetails, statusCode); - } - } - - public static void ApplyProblemDetailsDefaults(ProblemDetails problemDetails, int? statusCode) - { - // We allow StatusCode to be specified either on ProblemDetails or on the ObjectResult and use it to configure the other. - // This lets users write return Conflict(new Problem("some description")) - // or return Problem("some-problem", 422) and have the response have consistent fields. - if (problemDetails.Status is null) - { - if (statusCode is not null) - { - problemDetails.Status = statusCode; - } - else - { - problemDetails.Status = problemDetails is HttpValidationProblemDetails ? - StatusCodes.Status400BadRequest : - StatusCodes.Status500InternalServerError; - } - } - - if (ProblemDetailsDefaults.Defaults.TryGetValue(problemDetails.Status.Value, out var defaults)) - { - problemDetails.Title ??= defaults.Title; - problemDetails.Type ??= defaults.Type; + ProblemDetailsDefaults.Apply(problemDetails, statusCode); } } diff --git a/src/Http/Http.Results/src/JsonHttpResultOfT.cs b/src/Http/Http.Results/src/JsonHttpResultOfT.cs index 1a3203c3d233..d743591d547f 100644 --- a/src/Http/Http.Results/src/JsonHttpResultOfT.cs +++ b/src/Http/Http.Results/src/JsonHttpResultOfT.cs @@ -49,7 +49,7 @@ internal JsonHttpResult(TValue? value, int? statusCode, string? contentType, Jso if (value is ProblemDetails problemDetails) { - HttpResultsHelper.ApplyProblemDetailsDefaults(problemDetails, statusCode); + ProblemDetailsDefaults.Apply(problemDetails, statusCode); statusCode ??= problemDetails.Status; } diff --git a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj index de0934e22002..7808c52b5ec9 100644 --- a/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj +++ b/src/Http/Http.Results/src/Microsoft.AspNetCore.Http.Results.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/Http/Http.Results/src/ProblemHttpResult.cs b/src/Http/Http.Results/src/ProblemHttpResult.cs index 54c2259cd311..b0a42aaa050e 100644 --- a/src/Http/Http.Results/src/ProblemHttpResult.cs +++ b/src/Http/Http.Results/src/ProblemHttpResult.cs @@ -21,7 +21,7 @@ public sealed class ProblemHttpResult : IResult, IStatusCodeHttpResult, IContent internal ProblemHttpResult(ProblemDetails problemDetails) { ProblemDetails = problemDetails; - HttpResultsHelper.ApplyProblemDetailsDefaults(ProblemDetails, statusCode: null); + ProblemDetailsDefaults.Apply(ProblemDetails, statusCode: null); } /// diff --git a/src/Http/Http.Results/src/ValidationProblem.cs b/src/Http/Http.Results/src/ValidationProblem.cs index 71e5396d0238..36631d141e8b 100644 --- a/src/Http/Http.Results/src/ValidationProblem.cs +++ b/src/Http/Http.Results/src/ValidationProblem.cs @@ -22,7 +22,7 @@ internal ValidationProblem(HttpValidationProblemDetails problemDetails) } ProblemDetails = problemDetails; - HttpResultsHelper.ApplyProblemDetailsDefaults(ProblemDetails, statusCode: StatusCodes.Status400BadRequest); + ProblemDetailsDefaults.Apply(ProblemDetails, statusCode: StatusCodes.Status400BadRequest); } /// diff --git a/src/Http/Routing/src/Matching/AcceptsMatcherPolicy.cs b/src/Http/Routing/src/Matching/AcceptsMatcherPolicy.cs index 67c032148016..87931a0306f0 100644 --- a/src/Http/Routing/src/Matching/AcceptsMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/AcceptsMatcherPolicy.cs @@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Routing.Matching; internal sealed class AcceptsMatcherPolicy : MatcherPolicy, IEndpointComparerPolicy, INodeBuilderPolicy, IEndpointSelectorPolicy { + private static Endpoint? Http415Endpoint; internal const string Http415EndpointDisplayName = "415 HTTP Unsupported Media Type"; internal const string AnyContentType = "*/*"; @@ -258,8 +259,7 @@ public IReadOnlyList GetEdges(IReadOnlyList endpoints) private static Endpoint CreateRejectionEndpoint() { - return new Endpoint( - (context) => + return Http415Endpoint ??= new Endpoint(context => { context.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return Task.CompletedTask; diff --git a/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs b/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs index cc89d26a26a9..cea2791414da 100644 --- a/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs +++ b/src/Http/Routing/src/Matching/HttpMethodMatcherPolicy.cs @@ -404,12 +404,10 @@ private static Endpoint CreateRejectionEndpoint(IEnumerable? httpMethods return new Endpoint( (context) => { - context.Response.StatusCode = 405; - // Prevent ArgumentException from duplicate key if header already added, such as when the // request is re-executed by an error handler (see https://github.com/dotnet/aspnetcore/issues/6415) context.Response.Headers.Allow = allow; - + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; return Task.CompletedTask; }, EndpointMetadataCollection.Empty, diff --git a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageExtensions.cs b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageExtensions.cs index b8d748b7c729..39e1ecb24dd6 100644 --- a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageExtensions.cs +++ b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageExtensions.cs @@ -26,7 +26,8 @@ public static IApplicationBuilder UseDeveloperExceptionPage(this IApplicationBui throw new ArgumentNullException(nameof(app)); } - return app.UseMiddleware(); + app.Properties["analysis.NextMiddlewareName"] = "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware"; + return app.UseMiddleware(); } /// @@ -52,6 +53,7 @@ public static IApplicationBuilder UseDeveloperExceptionPage( throw new ArgumentNullException(nameof(options)); } - return app.UseMiddleware(Options.Create(options)); + app.Properties["analysis.NextMiddlewareName"] = "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware"; + return app.UseMiddleware(Options.Create(options)); } } diff --git a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddleware.cs b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddleware.cs index 979c71a2ad24..5ac530518fd9 100644 --- a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddleware.cs +++ b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddleware.cs @@ -2,19 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.RazorViews; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.StackTrace.Sources; -using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Diagnostics; @@ -23,24 +15,17 @@ namespace Microsoft.AspNetCore.Diagnostics; /// public class DeveloperExceptionPageMiddleware { - private readonly RequestDelegate _next; - private readonly DeveloperExceptionPageOptions _options; - private readonly ILogger _logger; - private readonly IFileProvider _fileProvider; - private readonly DiagnosticSource _diagnosticSource; - private readonly ExceptionDetailsProvider _exceptionDetailsProvider; - private readonly Func _exceptionHandler; - private static readonly MediaTypeHeaderValue _textHtmlMediaType = new MediaTypeHeaderValue("text/html"); + private readonly DeveloperExceptionPageMiddlewareImpl _innerMiddlewareImpl; /// /// Initializes a new instance of the class /// - /// - /// - /// + /// The representing the next middleware in the pipeline. + /// The options for configuring the middleware. + /// The used for logging. /// - /// - /// + /// The used for writing diagnostic messages. + /// The list of registered . public DeveloperExceptionPageMiddleware( RequestDelegate next, IOptions options, @@ -49,34 +34,14 @@ public DeveloperExceptionPageMiddleware( DiagnosticSource diagnosticSource, IEnumerable filters) { - if (next == null) - { - throw new ArgumentNullException(nameof(next)); - } - - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - if (filters == null) - { - throw new ArgumentNullException(nameof(filters)); - } - - _next = next; - _options = options.Value; - _logger = loggerFactory.CreateLogger(); - _fileProvider = _options.FileProvider ?? hostingEnvironment.ContentRootFileProvider; - _diagnosticSource = diagnosticSource; - _exceptionDetailsProvider = new ExceptionDetailsProvider(_fileProvider, _logger, _options.SourceCodeLineCount); - _exceptionHandler = DisplayException; - - foreach (var filter in filters.Reverse()) - { - var nextFilter = _exceptionHandler; - _exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter); - } + _innerMiddlewareImpl = new( + next, + options, + loggerFactory, + hostingEnvironment, + diagnosticSource, + filters, + problemDetailsService: null); } /// @@ -84,204 +49,6 @@ public DeveloperExceptionPageMiddleware( /// /// /// - public async Task Invoke(HttpContext context) - { - try - { - await _next(context); - } - catch (Exception ex) - { - _logger.UnhandledException(ex); - - if (context.Response.HasStarted) - { - _logger.ResponseStartedErrorPageMiddleware(); - throw; - } - - try - { - context.Response.Clear(); - - // Preserve the status code that would have been written by the server automatically when a BadHttpRequestException is thrown. - if (ex is BadHttpRequestException badHttpRequestException) - { - context.Response.StatusCode = badHttpRequestException.StatusCode; - } - else - { - context.Response.StatusCode = 500; - } - - await _exceptionHandler(new ErrorContext(context, ex)); - - const string eventName = "Microsoft.AspNetCore.Diagnostics.UnhandledException"; - if (_diagnosticSource.IsEnabled(eventName)) - { - WriteDiagnosticEvent(_diagnosticSource, eventName, new { httpContext = context, exception = ex }); - } - - return; - } - catch (Exception ex2) - { - // If there's a Exception while generating the error page, re-throw the original exception. - _logger.DisplayErrorPageException(ex2); - } - throw; - } - - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", - Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")] - static void WriteDiagnosticEvent(DiagnosticSource diagnosticSource, string name, TValue value) - => diagnosticSource.Write(name, value); - } - - // Assumes the response headers have not been sent. If they have, still attempt to write to the body. - private Task DisplayException(ErrorContext errorContext) - { - var httpContext = errorContext.HttpContext; - var headers = httpContext.Request.GetTypedHeaders(); - var acceptHeader = headers.Accept; - - // If the client does not ask for HTML just format the exception as plain text - if (acceptHeader == null || !acceptHeader.Any(h => h.IsSubsetOf(_textHtmlMediaType))) - { - httpContext.Response.ContentType = "text/plain; charset=utf-8"; - - var sb = new StringBuilder(); - sb.AppendLine(errorContext.Exception.ToString()); - sb.AppendLine(); - sb.AppendLine("HEADERS"); - sb.AppendLine("======="); - foreach (var pair in httpContext.Request.Headers) - { - sb.AppendLine(FormattableString.Invariant($"{pair.Key}: {pair.Value}")); - } - - return httpContext.Response.WriteAsync(sb.ToString()); - } - - if (errorContext.Exception is ICompilationException compilationException) - { - return DisplayCompilationException(httpContext, compilationException); - } - - return DisplayRuntimeException(httpContext, errorContext.Exception); - } - - private Task DisplayCompilationException( - HttpContext context, - ICompilationException compilationException) - { - var model = new CompilationErrorPageModel(_options); - - var errorPage = new CompilationErrorPage(model); - - if (compilationException.CompilationFailures == null) - { - return errorPage.ExecuteAsync(context); - } - - foreach (var compilationFailure in compilationException.CompilationFailures) - { - if (compilationFailure == null) - { - continue; - } - - var stackFrames = new List(); - var exceptionDetails = new ExceptionDetails(compilationFailure.FailureSummary!, stackFrames); - model.ErrorDetails.Add(exceptionDetails); - model.CompiledContent.Add(compilationFailure.CompiledContent); - - if (compilationFailure.Messages == null) - { - continue; - } - - var sourceLines = compilationFailure - .SourceFileContent? - .Split(new[] { Environment.NewLine }, StringSplitOptions.None); - - foreach (var item in compilationFailure.Messages) - { - if (item == null) - { - continue; - } - - var frame = new StackFrameSourceCodeInfo - { - File = compilationFailure.SourceFilePath, - Line = item.StartLine, - Function = string.Empty - }; - - if (sourceLines != null) - { - _exceptionDetailsProvider.ReadFrameContent(frame, sourceLines, item.StartLine, item.EndLine); - } - - frame.ErrorDetails = item.Message; - - stackFrames.Add(frame); - } - } - - return errorPage.ExecuteAsync(context); - } - - private Task DisplayRuntimeException(HttpContext context, Exception ex) - { - var endpoint = context.GetEndpoint(); - - EndpointModel? endpointModel = null; - if (endpoint != null) - { - endpointModel = new EndpointModel(); - endpointModel.DisplayName = endpoint.DisplayName; - - if (endpoint is RouteEndpoint routeEndpoint) - { - endpointModel.RoutePattern = routeEndpoint.RoutePattern.RawText; - endpointModel.Order = routeEndpoint.Order; - - var httpMethods = endpoint.Metadata.GetMetadata()?.HttpMethods; - if (httpMethods != null) - { - endpointModel.HttpMethods = string.Join(", ", httpMethods); - } - } - } - - var request = context.Request; - var title = Resources.ErrorPageHtml_Title; - - if (ex is BadHttpRequestException badHttpRequestException) - { - var badRequestReasonPhrase = WebUtilities.ReasonPhrases.GetReasonPhrase(badHttpRequestException.StatusCode); - - if (!string.IsNullOrEmpty(badRequestReasonPhrase)) - { - title = badRequestReasonPhrase; - } - } - - var model = new ErrorPageModel - { - Options = _options, - ErrorDetails = _exceptionDetailsProvider.GetDetails(ex), - Query = request.Query, - Cookies = request.Cookies, - Headers = request.Headers, - RouteValues = request.RouteValues, - Endpoint = endpointModel, - Title = title, - }; - - var errorPage = new ErrorPage(model); - return errorPage.ExecuteAsync(context); - } + public Task Invoke(HttpContext context) + => _innerMiddlewareImpl.Invoke(context); } diff --git a/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs new file mode 100644 index 000000000000..595ecabda93e --- /dev/null +++ b/src/Middleware/Diagnostics/src/DeveloperExceptionPage/DeveloperExceptionPageMiddlewareImpl.cs @@ -0,0 +1,337 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.RazorViews; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.StackTrace.Sources; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Diagnostics; + +/// +/// Captures synchronous and asynchronous exceptions from the pipeline and generates error responses. +/// +internal class DeveloperExceptionPageMiddlewareImpl +{ + private readonly RequestDelegate _next; + private readonly DeveloperExceptionPageOptions _options; + private readonly ILogger _logger; + private readonly IFileProvider _fileProvider; + private readonly DiagnosticSource _diagnosticSource; + private readonly ExceptionDetailsProvider _exceptionDetailsProvider; + private readonly Func _exceptionHandler; + private static readonly MediaTypeHeaderValue _textHtmlMediaType = new MediaTypeHeaderValue("text/html"); + private readonly IProblemDetailsService? _problemDetailsService; + + /// + /// Initializes a new instance of the class + /// + /// The representing the next middleware in the pipeline. + /// The options for configuring the middleware. + /// The used for logging. + /// + /// The used for writing diagnostic messages. + /// The list of registered . + /// The used for writing messages. + public DeveloperExceptionPageMiddlewareImpl( + RequestDelegate next, + IOptions options, + ILoggerFactory loggerFactory, + IWebHostEnvironment hostingEnvironment, + DiagnosticSource diagnosticSource, + IEnumerable filters, + IProblemDetailsService? problemDetailsService = null) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (filters == null) + { + throw new ArgumentNullException(nameof(filters)); + } + + _next = next; + _options = options.Value; + _logger = loggerFactory.CreateLogger(); + _fileProvider = _options.FileProvider ?? hostingEnvironment.ContentRootFileProvider; + _diagnosticSource = diagnosticSource; + _exceptionDetailsProvider = new ExceptionDetailsProvider(_fileProvider, _logger, _options.SourceCodeLineCount); + _exceptionHandler = DisplayException; + _problemDetailsService = problemDetailsService; + + foreach (var filter in filters.Reverse()) + { + var nextFilter = _exceptionHandler; + _exceptionHandler = errorContext => filter.HandleExceptionAsync(errorContext, nextFilter); + } + } + + /// + /// Process an individual request. + /// + /// + /// + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.UnhandledException(ex); + + if (context.Response.HasStarted) + { + _logger.ResponseStartedErrorPageMiddleware(); + throw; + } + + try + { + context.Response.Clear(); + + // Preserve the status code that would have been written by the server automatically when a BadHttpRequestException is thrown. + if (ex is BadHttpRequestException badHttpRequestException) + { + context.Response.StatusCode = badHttpRequestException.StatusCode; + } + else + { + context.Response.StatusCode = 500; + } + + await _exceptionHandler(new ErrorContext(context, ex)); + + const string eventName = "Microsoft.AspNetCore.Diagnostics.UnhandledException"; + if (_diagnosticSource.IsEnabled(eventName)) + { + WriteDiagnosticEvent(_diagnosticSource, eventName, new { httpContext = context, exception = ex }); + } + + return; + } + catch (Exception ex2) + { + // If there's a Exception while generating the error page, re-throw the original exception. + _logger.DisplayErrorPageException(ex2); + } + throw; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")] + static void WriteDiagnosticEvent(DiagnosticSource diagnosticSource, string name, TValue value) + => diagnosticSource.Write(name, value); + } + + // Assumes the response headers have not been sent. If they have, still attempt to write to the body. + private Task DisplayException(ErrorContext errorContext) + { + var httpContext = errorContext.HttpContext; + var headers = httpContext.Request.GetTypedHeaders(); + var acceptHeader = headers.Accept; + + // If the client does not ask for HTML just format the exception as plain text + if (acceptHeader == null || !acceptHeader.Any(h => h.IsSubsetOf(_textHtmlMediaType))) + { + return DisplayExceptionContent(errorContext); + } + + if (errorContext.Exception is ICompilationException compilationException) + { + return DisplayCompilationException(httpContext, compilationException); + } + + return DisplayRuntimeException(httpContext, errorContext.Exception); + } + + private async Task DisplayExceptionContent(ErrorContext errorContext) + { + var httpContext = errorContext.HttpContext; + + if (_problemDetailsService != null) + { + var problemDetails = new ProblemDetails + { + Title = TypeNameHelper.GetTypeDisplayName(errorContext.Exception.GetType()), + Detail = errorContext.Exception.Message, + Status = httpContext.Response.StatusCode + }; + + var exceptionDetails = _exceptionDetailsProvider.GetDetails(errorContext.Exception); + problemDetails.Extensions["exception"] = new + { + Details = exceptionDetails.Select(d => new + { + Message = d.ErrorMessage ?? d.Error?.Message, + Type = TypeNameHelper.GetTypeDisplayName(d.Error), + StackFrames = d.StackFrames, + }), + Headers = httpContext.Request.Headers, + Error = errorContext.Exception.ToString(), + Path = httpContext.Request.Path, + Endpoint = httpContext.GetEndpoint()?.ToString(), + RouteValues = httpContext.Features.Get()?.RouteValues, + }; + + await _problemDetailsService.WriteAsync(new() + { + HttpContext = httpContext, + ProblemDetails = problemDetails + }); + } + + // If the response has not started, assume the problem details was not written. + if (!httpContext.Response.HasStarted) + { + httpContext.Response.ContentType = "text/plain; charset=utf-8"; + + var sb = new StringBuilder(); + sb.AppendLine(errorContext.Exception.ToString()); + sb.AppendLine(); + sb.AppendLine("HEADERS"); + sb.AppendLine("======="); + foreach (var pair in httpContext.Request.Headers) + { + sb.AppendLine(FormattableString.Invariant($"{pair.Key}: {pair.Value}")); + } + + await httpContext.Response.WriteAsync(sb.ToString()); + } + } + + private Task DisplayCompilationException( + HttpContext context, + ICompilationException compilationException) + { + var model = new CompilationErrorPageModel(_options); + + var errorPage = new CompilationErrorPage(model); + + if (compilationException.CompilationFailures == null) + { + return errorPage.ExecuteAsync(context); + } + + foreach (var compilationFailure in compilationException.CompilationFailures) + { + if (compilationFailure == null) + { + continue; + } + + var stackFrames = new List(); + var exceptionDetails = new ExceptionDetails(compilationFailure.FailureSummary!, stackFrames); + model.ErrorDetails.Add(exceptionDetails); + model.CompiledContent.Add(compilationFailure.CompiledContent); + + if (compilationFailure.Messages == null) + { + continue; + } + + var sourceLines = compilationFailure + .SourceFileContent? + .Split(new[] { Environment.NewLine }, StringSplitOptions.None); + + foreach (var item in compilationFailure.Messages) + { + if (item == null) + { + continue; + } + + var frame = new StackFrameSourceCodeInfo + { + File = compilationFailure.SourceFilePath, + Line = item.StartLine, + Function = string.Empty + }; + + if (sourceLines != null) + { + _exceptionDetailsProvider.ReadFrameContent(frame, sourceLines, item.StartLine, item.EndLine); + } + + frame.ErrorDetails = item.Message; + + stackFrames.Add(frame); + } + } + + return errorPage.ExecuteAsync(context); + } + + private Task DisplayRuntimeException(HttpContext context, Exception ex) + { + var endpoint = context.GetEndpoint(); + + EndpointModel? endpointModel = null; + if (endpoint != null) + { + endpointModel = new EndpointModel(); + endpointModel.DisplayName = endpoint.DisplayName; + + if (endpoint is RouteEndpoint routeEndpoint) + { + endpointModel.RoutePattern = routeEndpoint.RoutePattern.RawText; + endpointModel.Order = routeEndpoint.Order; + + var httpMethods = endpoint.Metadata.GetMetadata()?.HttpMethods; + if (httpMethods != null) + { + endpointModel.HttpMethods = string.Join(", ", httpMethods); + } + } + } + + var request = context.Request; + var title = Resources.ErrorPageHtml_Title; + + if (ex is BadHttpRequestException badHttpRequestException) + { + var badRequestReasonPhrase = WebUtilities.ReasonPhrases.GetReasonPhrase(badHttpRequestException.StatusCode); + + if (!string.IsNullOrEmpty(badRequestReasonPhrase)) + { + title = badRequestReasonPhrase; + } + } + + var model = new ErrorPageModel + { + Options = _options, + ErrorDetails = _exceptionDetailsProvider.GetDetails(ex), + Query = request.Query, + Cookies = request.Cookies, + Headers = request.Headers, + RouteValues = request.RouteValues, + Endpoint = endpointModel, + Title = title, + }; + + var errorPage = new ErrorPage(model); + return errorPage.ExecuteAsync(context); + } +} diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerExtensions.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerExtensions.cs index 602b1bc2172d..b84e60b0c7e3 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerExtensions.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerExtensions.cs @@ -104,6 +104,10 @@ public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder a private static IApplicationBuilder SetExceptionHandlerMiddleware(IApplicationBuilder app, IOptions? options) { const string globalRouteBuilderKey = "__GlobalEndpointRouteBuilder"; + var problemDetailsService = app.ApplicationServices.GetService(); + + app.Properties["analysis.NextMiddlewareName"] = "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware"; + // Only use this path if there's a global router (in the 'WebApplication' case). if (app.Properties.TryGetValue(globalRouteBuilderKey, out var routeBuilder) && routeBuilder is not null) { @@ -131,15 +135,15 @@ private static IApplicationBuilder SetExceptionHandlerMiddleware(IApplicationBui options.Value.ExceptionHandler = builder.Build(); } - return new ExceptionHandlerMiddleware(next, loggerFactory, options, diagnosticListener).Invoke; + return new ExceptionHandlerMiddlewareImpl(next, loggerFactory, options, diagnosticListener, problemDetailsService).Invoke; }); } if (options is null) { - return app.UseMiddleware(); + return app.UseMiddleware(); } - return app.UseMiddleware(options); + return app.UseMiddleware(options); } } diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddleware.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddleware.cs index d7dd4ff90e1d..5b9f87514326 100644 --- a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddleware.cs +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddleware.cs @@ -2,11 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.ExceptionServices; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -17,11 +14,7 @@ namespace Microsoft.AspNetCore.Diagnostics; /// public class ExceptionHandlerMiddleware { - private readonly RequestDelegate _next; - private readonly ExceptionHandlerOptions _options; - private readonly ILogger _logger; - private readonly Func _clearCacheHeadersDelegate; - private readonly DiagnosticListener _diagnosticListener; + private readonly ExceptionHandlerMiddlewareImpl _innerMiddlewareImpl; /// /// Creates a new @@ -36,22 +29,12 @@ public ExceptionHandlerMiddleware( IOptions options, DiagnosticListener diagnosticListener) { - _next = next; - _options = options.Value; - _logger = loggerFactory.CreateLogger(); - _clearCacheHeadersDelegate = ClearCacheHeaders; - _diagnosticListener = diagnosticListener; - if (_options.ExceptionHandler == null) - { - if (_options.ExceptionHandlingPath == null) - { - throw new InvalidOperationException(Resources.ExceptionHandlerOptions_NotConfiguredCorrectly); - } - else - { - _options.ExceptionHandler = _next; - } - } + _innerMiddlewareImpl = new ( + next, + loggerFactory, + options, + diagnosticListener, + problemDetailsService: null); } /// @@ -59,135 +42,5 @@ public ExceptionHandlerMiddleware( /// /// The for the current request. public Task Invoke(HttpContext context) - { - ExceptionDispatchInfo edi; - try - { - var task = _next(context); - if (!task.IsCompletedSuccessfully) - { - return Awaited(this, context, task); - } - - return Task.CompletedTask; - } - catch (Exception exception) - { - // Get the Exception, but don't continue processing in the catch block as its bad for stack usage. - edi = ExceptionDispatchInfo.Capture(exception); - } - - return HandleException(context, edi); - - static async Task Awaited(ExceptionHandlerMiddleware middleware, HttpContext context, Task task) - { - ExceptionDispatchInfo? edi = null; - try - { - await task; - } - catch (Exception exception) - { - // Get the Exception, but don't continue processing in the catch block as its bad for stack usage. - edi = ExceptionDispatchInfo.Capture(exception); - } - - if (edi != null) - { - await middleware.HandleException(context, edi); - } - } - } - - private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi) - { - _logger.UnhandledException(edi.SourceException); - // We can't do anything if the response has already started, just abort. - if (context.Response.HasStarted) - { - _logger.ResponseStartedErrorHandler(); - edi.Throw(); - } - - PathString originalPath = context.Request.Path; - if (_options.ExceptionHandlingPath.HasValue) - { - context.Request.Path = _options.ExceptionHandlingPath; - } - try - { - var exceptionHandlerFeature = new ExceptionHandlerFeature() - { - Error = edi.SourceException, - Path = originalPath.Value!, - Endpoint = context.GetEndpoint(), - RouteValues = context.Features.Get()?.RouteValues - }; - - ClearHttpContext(context); - - context.Features.Set(exceptionHandlerFeature); - context.Features.Set(exceptionHandlerFeature); - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response); - - await _options.ExceptionHandler!(context); - - // If the response has already started, assume exception handler was successful. - if (context.Response.HasStarted || context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response) - { - const string eventName = "Microsoft.AspNetCore.Diagnostics.HandledException"; - if (_diagnosticListener.IsEnabled() && _diagnosticListener.IsEnabled(eventName)) - { - WriteDiagnosticEvent(_diagnosticListener, eventName, new { httpContext = context, exception = edi.SourceException }); - } - - return; - } - - edi = ExceptionDispatchInfo.Capture(new InvalidOperationException($"The exception handler configured on {nameof(ExceptionHandlerOptions)} produced a 404 status response. " + - $"This {nameof(InvalidOperationException)} containing the original exception was thrown since this is often due to a misconfigured {nameof(ExceptionHandlerOptions.ExceptionHandlingPath)}. " + - $"If the exception handler is expected to return 404 status responses then set {nameof(ExceptionHandlerOptions.AllowStatusCode404Response)} to true.", edi.SourceException)); - } - catch (Exception ex2) - { - // Suppress secondary exceptions, re-throw the original. - _logger.ErrorHandlerException(ex2); - } - finally - { - context.Request.Path = originalPath; - } - - edi.Throw(); // Re-throw wrapped exception or the original if we couldn't handle it - - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", - Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")] - static void WriteDiagnosticEvent(DiagnosticSource diagnosticSource, string name, TValue value) - => diagnosticSource.Write(name, value); - } - - private static void ClearHttpContext(HttpContext context) - { - context.Response.Clear(); - - // An endpoint may have already been set. Since we're going to re-invoke the middleware pipeline we need to reset - // the endpoint and route values to ensure things are re-calculated. - context.SetEndpoint(endpoint: null); - var routeValuesFeature = context.Features.Get(); - if (routeValuesFeature != null) - { - routeValuesFeature.RouteValues = null!; - } - } - - private static Task ClearCacheHeaders(object state) - { - var headers = ((HttpResponse)state).Headers; - headers.CacheControl = "no-cache,no-store"; - headers.Pragma = "no-cache"; - headers.Expires = "-1"; - headers.ETag = default; - return Task.CompletedTask; - } + => _innerMiddlewareImpl.Invoke(context); } diff --git a/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs new file mode 100644 index 000000000000..48939e0e1f9a --- /dev/null +++ b/src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerMiddlewareImpl.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Diagnostics; + +/// +/// A middleware for handling exceptions in the application. +/// +internal class ExceptionHandlerMiddlewareImpl +{ + private const int DefaultStatusCode = StatusCodes.Status500InternalServerError; + + private readonly RequestDelegate _next; + private readonly ExceptionHandlerOptions _options; + private readonly ILogger _logger; + private readonly Func _clearCacheHeadersDelegate; + private readonly DiagnosticListener _diagnosticListener; + private readonly IProblemDetailsService? _problemDetailsService; + + /// + /// Creates a new + /// + /// The representing the next middleware in the pipeline. + /// The used for logging. + /// The options for configuring the middleware. + /// The used for writing diagnostic messages. + /// The used for writing messages. + public ExceptionHandlerMiddlewareImpl( + RequestDelegate next, + ILoggerFactory loggerFactory, + IOptions options, + DiagnosticListener diagnosticListener, + IProblemDetailsService? problemDetailsService = null) + { + _next = next; + _options = options.Value; + _logger = loggerFactory.CreateLogger(); + _clearCacheHeadersDelegate = ClearCacheHeaders; + _diagnosticListener = diagnosticListener; + _problemDetailsService = problemDetailsService; + + if (_options.ExceptionHandler == null) + { + if (_options.ExceptionHandlingPath == null) + { + if (problemDetailsService == null) + { + throw new InvalidOperationException(Resources.ExceptionHandlerOptions_NotConfiguredCorrectly); + } + } + else + { + _options.ExceptionHandler = _next; + } + } + } + + /// + /// Executes the middleware. + /// + /// The for the current request. + public Task Invoke(HttpContext context) + { + ExceptionDispatchInfo edi; + try + { + var task = _next(context); + if (!task.IsCompletedSuccessfully) + { + return Awaited(this, context, task); + } + + return Task.CompletedTask; + } + catch (Exception exception) + { + // Get the Exception, but don't continue processing in the catch block as its bad for stack usage. + edi = ExceptionDispatchInfo.Capture(exception); + } + + return HandleException(context, edi); + + static async Task Awaited(ExceptionHandlerMiddlewareImpl middleware, HttpContext context, Task task) + { + ExceptionDispatchInfo? edi = null; + try + { + await task; + } + catch (Exception exception) + { + // Get the Exception, but don't continue processing in the catch block as its bad for stack usage. + edi = ExceptionDispatchInfo.Capture(exception); + } + + if (edi != null) + { + await middleware.HandleException(context, edi); + } + } + } + + private async Task HandleException(HttpContext context, ExceptionDispatchInfo edi) + { + _logger.UnhandledException(edi.SourceException); + // We can't do anything if the response has already started, just abort. + if (context.Response.HasStarted) + { + _logger.ResponseStartedErrorHandler(); + edi.Throw(); + } + + PathString originalPath = context.Request.Path; + if (_options.ExceptionHandlingPath.HasValue) + { + context.Request.Path = _options.ExceptionHandlingPath; + } + try + { + var exceptionHandlerFeature = new ExceptionHandlerFeature() + { + Error = edi.SourceException, + Path = originalPath.Value!, + Endpoint = context.GetEndpoint(), + RouteValues = context.Features.Get()?.RouteValues + }; + + ClearHttpContext(context); + + context.Features.Set(exceptionHandlerFeature); + context.Features.Set(exceptionHandlerFeature); + context.Response.StatusCode = DefaultStatusCode; + context.Response.OnStarting(_clearCacheHeadersDelegate, context.Response); + + if (_options.ExceptionHandler != null) + { + await _options.ExceptionHandler!(context); + } + else + { + await _problemDetailsService!.WriteAsync(new () + { + HttpContext = context, + AdditionalMetadata = exceptionHandlerFeature.Endpoint?.Metadata, + ProblemDetails = { Status = DefaultStatusCode } + }); + } + + // If the response has already started, assume exception handler was successful. + if (context.Response.HasStarted || context.Response.StatusCode != StatusCodes.Status404NotFound || _options.AllowStatusCode404Response) + { + const string eventName = "Microsoft.AspNetCore.Diagnostics.HandledException"; + if (_diagnosticListener.IsEnabled() && _diagnosticListener.IsEnabled(eventName)) + { + WriteDiagnosticEvent(_diagnosticListener, eventName, new { httpContext = context, exception = edi.SourceException }); + } + + return; + } + + edi = ExceptionDispatchInfo.Capture(new InvalidOperationException($"The exception handler configured on {nameof(ExceptionHandlerOptions)} produced a 404 status response. " + + $"This {nameof(InvalidOperationException)} containing the original exception was thrown since this is often due to a misconfigured {nameof(ExceptionHandlerOptions.ExceptionHandlingPath)}. " + + $"If the exception handler is expected to return 404 status responses then set {nameof(ExceptionHandlerOptions.AllowStatusCode404Response)} to true.", edi.SourceException)); + } + catch (Exception ex2) + { + // Suppress secondary exceptions, re-throw the original. + _logger.ErrorHandlerException(ex2); + } + finally + { + context.Request.Path = originalPath; + } + + edi.Throw(); // Re-throw wrapped exception or the original if we couldn't handle it + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")] + static void WriteDiagnosticEvent(DiagnosticSource diagnosticSource, string name, TValue value) + => diagnosticSource.Write(name, value); + } + + private static void ClearHttpContext(HttpContext context) + { + context.Response.Clear(); + + // An endpoint may have already been set. Since we're going to re-invoke the middleware pipeline we need to reset + // the endpoint and route values to ensure things are re-calculated. + context.SetEndpoint(endpoint: null); + var routeValuesFeature = context.Features.Get(); + if (routeValuesFeature != null) + { + routeValuesFeature.RouteValues = null!; + } + } + + private static Task ClearCacheHeaders(object state) + { + var headers = ((HttpResponse)state).Headers; + headers.CacheControl = "no-cache,no-store"; + headers.Pragma = "no-cache"; + headers.Expires = "-1"; + headers.ETag = default; + return Task.CompletedTask; + } +} diff --git a/src/Middleware/Diagnostics/src/Resources.resx b/src/Middleware/Diagnostics/src/Resources.resx index 6e62f494942e..6ac14a9a8b68 100644 --- a/src/Middleware/Diagnostics/src/Resources.resx +++ b/src/Middleware/Diagnostics/src/Resources.resx @@ -248,7 +248,7 @@ Environment: - An error occurred when configuring the exception handler middleware. Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'. Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });'. + An error occurred when configuring the exception handler middleware. Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'. Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });' or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'. No route values. @@ -280,4 +280,4 @@ Name - + \ No newline at end of file diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs index 56c00d2ea67a..496e02d763e3 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Builder; @@ -19,15 +20,27 @@ public class StatusCodePagesOptions /// public StatusCodePagesOptions() { - HandleAsync = context => + HandleAsync = async context => { - // TODO: Render with a pre-compiled html razor view. var statusCode = context.HttpContext.Response.StatusCode; - var body = BuildResponseBody(statusCode); + if (context.HttpContext.RequestServices.GetService() is { } problemDetailsService) + { + await problemDetailsService.WriteAsync(new () + { + HttpContext = context.HttpContext, + ProblemDetails = { Status = statusCode } + }); + } + + // TODO: Render with a pre-compiled html razor view. + if (!context.HttpContext.Response.HasStarted) + { + var body = BuildResponseBody(statusCode); - context.HttpContext.Response.ContentType = "text/plain"; - return context.HttpContext.Response.WriteAsync(body); + context.HttpContext.Response.ContentType = "text/plain"; + await context.HttpContext.Response.WriteAsync(body); + } }; } diff --git a/src/Middleware/Diagnostics/test/FunctionalTests/DeveloperExceptionPageSampleTest.cs b/src/Middleware/Diagnostics/test/FunctionalTests/DeveloperExceptionPageSampleTest.cs index 7691e4a9c005..25770a6d51a1 100644 --- a/src/Middleware/Diagnostics/test/FunctionalTests/DeveloperExceptionPageSampleTest.cs +++ b/src/Middleware/Diagnostics/test/FunctionalTests/DeveloperExceptionPageSampleTest.cs @@ -1,8 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http.Json; namespace Microsoft.AspNetCore.Diagnostics.FunctionalTests; @@ -29,4 +32,22 @@ public async Task DeveloperExceptionPage_ShowsError() Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); Assert.Contains("Exception: Demonstration exception.", body); } + + [Fact] + public async Task DeveloperExceptionPage_ShowsProblemDetails_WhenHtmlNotAccepted() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.NotNull(body); + Assert.Equal(500, body.Status); + Assert.Contains("Demonstration exception", body.Detail); + } } diff --git a/src/Middleware/Diagnostics/test/FunctionalTests/ProblemDetailsExceptionHandlerSampleTest.cs b/src/Middleware/Diagnostics/test/FunctionalTests/ProblemDetailsExceptionHandlerSampleTest.cs new file mode 100644 index 000000000000..06b54ccfd05f --- /dev/null +++ b/src/Middleware/Diagnostics/test/FunctionalTests/ProblemDetailsExceptionHandlerSampleTest.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http.Json; +using System.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Diagnostics.FunctionalTests; + +public class ProblemDetailsExceptionHandlerSampleTest : IClassFixture> +{ + public ProblemDetailsExceptionHandlerSampleTest(TestFixture fixture) + { + Client = fixture.Client; + } + + public HttpClient Client { get; } + + [Fact] + public async Task ExceptionHandlerPage_ProducesProblemDetails() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/throw"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.NotNull(body); + Assert.Equal(500, body.Status); + } +} diff --git a/src/Middleware/Diagnostics/test/FunctionalTests/StatusCodeSampleTest.cs b/src/Middleware/Diagnostics/test/FunctionalTests/StatusCodeSampleTest.cs index 193999955a41..3299200c2827 100644 --- a/src/Middleware/Diagnostics/test/FunctionalTests/StatusCodeSampleTest.cs +++ b/src/Middleware/Diagnostics/test/FunctionalTests/StatusCodeSampleTest.cs @@ -1,9 +1,13 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Net; using System.Net.Http; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http.Json; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System.Net.Http.Headers; namespace Microsoft.AspNetCore.Diagnostics.FunctionalTests; @@ -70,4 +74,22 @@ public async Task StatusCodePageOptions_IncludesSemicolon__AndReasonPhrase_WhenR Assert.Contains(";", responseBody); Assert.Contains(statusCodeReasonPhrase, responseBody); } + + [Fact] + public async Task StatusCodePage_ProducesProblemDetails() + { + // Arrange + var httpStatusCode = 400; + var request = new HttpRequestMessage(HttpMethod.Get, $"http://localhost?statuscode={httpStatusCode}"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(body); + Assert.Equal(400, body.Status); + } } diff --git a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs index ee3582ab0b9e..9b7053d4c050 100644 --- a/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs +++ b/src/Middleware/Diagnostics/test/UnitTests/ExceptionHandlerTest.cs @@ -523,7 +523,8 @@ public void UsingExceptionHandler_ThrowsAnException_WhenExceptionHandlingPathNot // Assert Assert.Equal("An error occurred when configuring the exception handler middleware. " + "Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'. " + - "Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });'.", + "Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });' " + + "or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'.", exception.Message); } diff --git a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj index 40106c9233b9..39fb247b9283 100644 --- a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/DeveloperExceptionPageSample.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) diff --git a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Startup.cs b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Startup.cs index f3592f61c7b8..a81b1593ca4d 100644 --- a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Startup.cs +++ b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Startup.cs @@ -7,6 +7,11 @@ namespace DeveloperExceptionPageSample; public class Startup { + public void ConfigureServices(IServiceCollection services) + { + services.AddProblemDetails(); + } + public void Configure(IApplicationBuilder app) { app.Use((context, next) => @@ -21,7 +26,8 @@ public void Configure(IApplicationBuilder app) c => null, RoutePatternFactory.Parse("/"), 0, - new EndpointMetadataCollection(new HttpMethodMetadata(new[] { "GET", "POST" })), + new EndpointMetadataCollection( + new HttpMethodMetadata(new[] { "GET", "POST" })), "Endpoint display name"); context.SetEndpoint(endpoint); diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj index 907fa264f876..2faf636c4914 100644 --- a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/ExceptionHandlerSample.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -11,5 +11,4 @@ - diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Program.cs b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Program.cs new file mode 100644 index 000000000000..f49dd9f04212 --- /dev/null +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Program.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace ExceptionHandlerSample; + +public class Program +{ + public static Task Main(string[] args) + { + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel() + .UseIISIntegration() + .UseStartup(); + }) + .Build(); + + return host.RunAsync(); + } +} diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Startup.cs b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Startup.cs index 4d9402a535c3..b54320e19583 100644 --- a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Startup.cs +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Startup.cs @@ -53,20 +53,5 @@ public void Configure(IApplicationBuilder app) await context.Response.WriteAsync("\r\n"); }); } - - public static Task Main(string[] args) - { - var host = new HostBuilder() - .ConfigureWebHost(webHostBuilder => - { - webHostBuilder - .UseKestrel() - .UseIISIntegration() - .UseStartup(); - }) - .Build(); - - return host.RunAsync(); - } } diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/StartupWithProblemDetails.cs b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/StartupWithProblemDetails.cs new file mode 100644 index 000000000000..ec842f666713 --- /dev/null +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/StartupWithProblemDetails.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http.Metadata; + +namespace ExceptionHandlerSample; + +public class StartupWithProblemDetails +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddProblemDetails(); + } + + public void Configure(IApplicationBuilder app) + { + // Configure the error handler to produces a ProblemDetails. + app.UseExceptionHandler(); + + // The broken section of our application. + app.Map("/throw", throwApp => + { + throwApp.Run(context => { throw new Exception("Application Exception"); }); + }); + + app.UseStaticFiles(); + + // The home page. + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync("Welcome to the sample

\r\n"); + await context.Response.WriteAsync("Click here to throw an exception: throw\r\n"); + await context.Response.WriteAsync("\r\n"); + }); + } +} + diff --git a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Startup.cs b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Startup.cs index c1c8fd1e3f67..ba0bda543a72 100644 --- a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Startup.cs +++ b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Startup.cs @@ -11,6 +11,11 @@ namespace StatusCodePagesSample; public class Startup { + public void ConfigureServices(IServiceCollection services) + { + services.AddProblemDetails(); + } + public void Configure(IApplicationBuilder app) { app.UseDeveloperExceptionPage(); diff --git a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj index 755410745d90..9c24fc8c3f64 100644 --- a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj +++ b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/StatusCodePagesSample.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) diff --git a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiConventionApplicationModelConvention.cs b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiConventionApplicationModelConvention.cs index a89c06c34fe4..cba692149f27 100644 --- a/src/Mvc/Mvc.Core/src/ApplicationModels/ApiConventionApplicationModelConvention.cs +++ b/src/Mvc/Mvc.Core/src/ApplicationModels/ApiConventionApplicationModelConvention.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/ApiBehaviorOptionsSetup.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/ApiBehaviorOptionsSetup.cs index ffc39f12a92e..38c956123523 100644 --- a/src/Mvc/Mvc.Core/src/DependencyInjection/ApiBehaviorOptionsSetup.cs +++ b/src/Mvc/Mvc.Core/src/DependencyInjection/ApiBehaviorOptionsSetup.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Options; diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index 7aaa42d5afc4..f81bd4df0bb3 100644 --- a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Linq; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; @@ -254,7 +255,6 @@ internal static void AddMvcCoreServices(IServiceCollection services) services.TryAddSingleton, ContentResultExecutor>(); services.TryAddSingleton, SystemTextJsonResultExecutor>(); services.TryAddSingleton(); - services.TryAddSingleton(); // // Route Handlers @@ -281,6 +281,10 @@ internal static void AddMvcCoreServices(IServiceCollection services) services.TryAddSingleton(); // Sets ApplicationBuilder on MiddlewareFilterBuilder services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + // ProblemDetails + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); } private static void ConfigureDefaultServices(IServiceCollection services) diff --git a/src/Mvc/Mvc.Core/src/Formatters/TextOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/TextOutputFormatter.cs index 4bca0413db8a..709acb78b0a6 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/TextOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/TextOutputFormatter.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -132,8 +133,18 @@ public override Task WriteAsync(OutputFormatterWriteContext context) } else { - var response = context.HttpContext.Response; - response.StatusCode = StatusCodes.Status406NotAcceptable; + const int statusCode = StatusCodes.Status406NotAcceptable; + context.HttpContext.Response.StatusCode = statusCode; + + if (context.HttpContext.RequestServices.GetService() is { } problemDetailsService) + { + return problemDetailsService.WriteAsync(new () + { + HttpContext = context.HttpContext, + ProblemDetails = { Status = statusCode } + }).AsTask(); + } + return Task.CompletedTask; } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/DefaultApiProblemDetailsWriter.cs b/src/Mvc/Mvc.Core/src/Infrastructure/DefaultApiProblemDetailsWriter.cs new file mode 100644 index 000000000000..f480c386358e --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Infrastructure/DefaultApiProblemDetailsWriter.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure; + +internal sealed class DefaultApiProblemDetailsWriter : IProblemDetailsWriter +{ + private readonly OutputFormatterSelector _formatterSelector; + private readonly IHttpResponseStreamWriterFactory _writerFactory; + private readonly ProblemDetailsFactory _problemDetailsFactory; + private readonly ApiBehaviorOptions _apiBehaviorOptions; + + private static readonly MediaTypeCollection _problemContentTypes = new() + { + "application/problem+json", + "application/problem+xml" + }; + + public DefaultApiProblemDetailsWriter( + OutputFormatterSelector formatterSelector, + IHttpResponseStreamWriterFactory writerFactory, + ProblemDetailsFactory problemDetailsFactory, + IOptions apiBehaviorOptions) + { + _formatterSelector = formatterSelector; + _writerFactory = writerFactory; + _problemDetailsFactory = problemDetailsFactory; + _apiBehaviorOptions = apiBehaviorOptions.Value; + } + + public bool CanWrite(ProblemDetailsContext context) + { + var controllerAttribute = context.AdditionalMetadata?.GetMetadata() ?? + context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); + + return controllerAttribute != null; + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + { + var apiControllerAttribute = context.AdditionalMetadata?.GetMetadata() ?? + context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); + + if (apiControllerAttribute is null || _apiBehaviorOptions.SuppressMapClientErrors) + { + // In this case we don't want to write + return ValueTask.CompletedTask; + } + + // Recreating the problem details to get all customizations + // from the factory + var problemDetails = _problemDetailsFactory.CreateProblemDetails( + context.HttpContext, + context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode, + context.ProblemDetails.Title, + context.ProblemDetails.Type, + context.ProblemDetails.Detail, + context.ProblemDetails.Instance); + + if (context.ProblemDetails?.Extensions is not null) + { + foreach (var extension in context.ProblemDetails.Extensions) + { + problemDetails.Extensions[extension.Key] = extension.Value; + } + } + + var formatterContext = new OutputFormatterWriteContext( + context.HttpContext, + _writerFactory.CreateWriter, + typeof(ProblemDetails), + problemDetails); + + var selectedFormatter = _formatterSelector.SelectFormatter( + formatterContext, + Array.Empty(), + _problemContentTypes); + + if (selectedFormatter == null) + { + return ValueTask.CompletedTask; + } + + return new ValueTask(selectedFormatter.WriteAsync(formatterContext)); + } +} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs b/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs index f77eed19ecd1..c08e86de6b9f 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs @@ -13,10 +13,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure; internal sealed class DefaultProblemDetailsFactory : ProblemDetailsFactory { private readonly ApiBehaviorOptions _options; + private readonly Action? _configure; - public DefaultProblemDetailsFactory(IOptions options) + public DefaultProblemDetailsFactory( + IOptions options, + IOptions? problemDetailsOptions = null) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _configure = problemDetailsOptions?.Value?.CustomizeProblemDetails; } public override ProblemDetails CreateProblemDetails( @@ -93,5 +97,7 @@ private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails { problemDetails.Extensions["traceId"] = traceId; } + + _configure?.Invoke(new() { HttpContext = httpContext!, ProblemDetails = problemDetails }); } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs index 1c4ddb95ab88..d8bab1ab8ef6 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs @@ -6,6 +6,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -109,12 +110,24 @@ private Task ExecuteAsyncCore(ActionContext context, ObjectResult result, Type? formatterContext, (IList)result.Formatters ?? Array.Empty(), result.ContentTypes); + if (selectedFormatter == null) { // No formatter supports this. Log.NoFormatter(Logger, formatterContext, result.ContentTypes); - context.HttpContext.Response.StatusCode = StatusCodes.Status406NotAcceptable; + const int statusCode = StatusCodes.Status406NotAcceptable; + context.HttpContext.Response.StatusCode = statusCode; + + if (context.HttpContext.RequestServices.GetService() is { } problemDetailsService) + { + return problemDetailsService.WriteAsync(new() + { + HttpContext = context.HttpContext, + ProblemDetails = { Status = statusCode } + }).AsTask(); + } + return Task.CompletedTask; } diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index d437875d6aa5..304b7408abaf 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC core components. Contains common action result types, attribute routing, application model conventions, API explorer, application parts, filters, formatters, model binding, and more. @@ -28,9 +28,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute - - - + diff --git a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs index bd724d5ea759..a82122536794 100644 --- a/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs +++ b/src/Mvc/Mvc.Core/src/Properties/AssemblyInfo.cs @@ -6,9 +6,7 @@ using Microsoft.AspNetCore.Mvc.Formatters; [assembly: TypeForwardedTo(typeof(InputFormatterException))] -#pragma warning disable RS0016 // Suppress PublicAPI analyzer [assembly: TypeForwardedTo(typeof(ProblemDetails))] -#pragma warning restore RS0016 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.ApiExplorer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index dc8c6f2136b6..9742d6caa946 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -7,6 +7,19 @@ Microsoft.AspNetCore.Mvc.ApplicationModels.InferParameterBindingInfoConvention.I *REMOVED*Microsoft.AspNetCore.Mvc.ControllerBase.TryUpdateModelAsync(TModel! model, string! prefix, params System.Linq.Expressions.Expression!>![]! includeExpressions) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Mvc.ControllerBase.TryUpdateModelAsync(TModel! model, string! prefix, Microsoft.AspNetCore.Mvc.ModelBinding.IValueProvider! valueProvider, params System.Linq.Expressions.Expression!>![]! includeExpressions) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Mvc.ControllerBase.TryUpdateModelAsync(TModel! model, string! prefix, params System.Linq.Expressions.Expression!>![]! includeExpressions) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Mvc.ProblemDetails (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary! (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string? (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) +Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void (forwarded, contained in Microsoft.AspNetCore.Http.Abstractions) Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TryParseModelBinderProvider Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TryParseModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext! context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder? Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TryParseModelBinderProvider.TryParseModelBinderProvider() -> void diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs index fc4d24cba9d2..c4dddb5d711a 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/ApiBehaviorApplicationModelProviderTest.cs @@ -3,10 +3,8 @@ using System.Reflection; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs index f0a483355fcd..37be61a23f0b 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataConventionTest.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Moq; diff --git a/src/Mvc/Mvc.Core/test/DependencyInjection/ApiBehaviorOptionsSetupTest.cs b/src/Mvc/Mvc.Core/test/DependencyInjection/ApiBehaviorOptionsSetupTest.cs index 4cec9eb27fdd..d1e7be87785f 100644 --- a/src/Mvc/Mvc.Core/test/DependencyInjection/ApiBehaviorOptionsSetupTest.cs +++ b/src/Mvc/Mvc.Core/test/DependencyInjection/ApiBehaviorOptionsSetupTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; @@ -16,7 +16,7 @@ public class ApiBehaviorOptionsSetupTest public void Configure_AddsClientErrorMappings() { // Arrange - var expected = new[] { 400, 401, 403, 404, 406, 409, 415, 422, 500, }; + var expected = new[] { 400, 401, 403, 404, 405, 406, 409, 415, 422, 500, }; var optionsSetup = new ApiBehaviorOptionsSetup(); var options = new ApiBehaviorOptions(); diff --git a/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs index b3eb866ea841..d105879e827c 100644 --- a/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs +++ b/src/Mvc/Mvc.Core/test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.ApplicationModels; @@ -323,6 +324,13 @@ private Dictionary MultiRegistrationServiceTypes typeof(DynamicControllerEndpointMatcherPolicy), } }, + { + typeof(IProblemDetailsWriter), + new Type[] + { + typeof(DefaultApiProblemDetailsWriter), + } + }, }; } } diff --git a/src/Mvc/Mvc.Core/test/Formatters/TextOutputFormatterTests.cs b/src/Mvc/Mvc.Core/test/Formatters/TextOutputFormatterTests.cs index 426e286cb886..fb4b1b34484f 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/TextOutputFormatterTests.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/TextOutputFormatterTests.cs @@ -3,6 +3,7 @@ using System.Text; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Moq; @@ -215,7 +216,7 @@ public async Task WriteAsync_ReturnsNotAcceptable_IfSelectCharacterEncodingRetur formatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/json")); var context = new OutputFormatterWriteContext( - new DefaultHttpContext(), + new DefaultHttpContext() { RequestServices = new ServiceCollection().BuildServiceProvider() }, new TestHttpResponseStreamWriterFactory().CreateWriter, objectType: null, @object: null) diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/DefaultApiProblemDetailsWriterTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/DefaultApiProblemDetailsWriterTest.cs new file mode 100644 index 000000000000..e918bcf5c49e --- /dev/null +++ b/src/Mvc/Mvc.Core/test/Infrastructure/DefaultApiProblemDetailsWriterTest.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Mvc.Infrastructure; + +public class DefaultApiProblemDetailsWriterTest +{ + + [Fact] + public async Task WriteAsync_Works() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream); + + var expectedProblem = new ProblemDetails() + { + Detail = "Custom Bad Request", + Instance = "Custom Bad Request", + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1-custom", + Title = "Custom Bad Request", + }; + var problemDetailsContext = new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = expectedProblem + }; + + //Act + await writer.WriteAsync(problemDetailsContext); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Equal(expectedProblem.Status, problemDetails.Status); + Assert.Equal(expectedProblem.Type, problemDetails.Type); + Assert.Equal(expectedProblem.Title, problemDetails.Title); + Assert.Equal(expectedProblem.Detail, problemDetails.Detail); + Assert.Equal(expectedProblem.Instance, problemDetails.Instance); + } + + [Fact] + public async Task WriteAsync_AddExtensions() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream); + var expectedProblem = new ProblemDetails(); + expectedProblem.Extensions["Extension1"] = "Extension1-Value"; + expectedProblem.Extensions["Extension2"] = "Extension2-Value"; + + var problemDetailsContext = new ProblemDetailsContext() + { + HttpContext = context, + ProblemDetails = expectedProblem + }; + + //Act + await writer.WriteAsync(problemDetailsContext); + + //Assert + stream.Position = 0; + var problemDetails = await JsonSerializer.DeserializeAsync(stream); + Assert.NotNull(problemDetails); + Assert.Contains("Extension1", problemDetails.Extensions); + Assert.Contains("Extension2", problemDetails.Extensions); + } + + [Fact] + public void CanWrite_ReturnsFalse_WhenNotController() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, metadata: EndpointMetadataCollection.Empty); + + //Act + var result = writer.CanWrite(new() { HttpContext = context }); + + //Assert + Assert.False(result); + } + + [Fact] + public void CanWrite_ReturnsTrue_WhenController() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, metadata: new EndpointMetadataCollection(new ControllerAttribute())); + + //Act + var result = writer.CanWrite(new() { HttpContext = context }); + + //Assert + Assert.True(result); + } + + [Fact] + public async Task WriteAsync_Skip_WhenNotApiController() + { + // Arrange + var writer = GetWriter(); + var stream = new MemoryStream(); + var context = CreateContext(stream, metadata: new EndpointMetadataCollection(new ControllerAttribute())); + + //Act + await writer.WriteAsync(new() { HttpContext = context }); + + //Assert + Assert.Equal(0, stream.Position); + Assert.Equal(0, stream.Length); + } + + [Fact] + public async Task WriteAsync_Skip_WhenSuppressMapClientErrors() + { + // Arrange + var writer = GetWriter(options: new ApiBehaviorOptions() { SuppressMapClientErrors = true }); + var stream = new MemoryStream(); + var context = CreateContext(stream); + + //Act + await writer.WriteAsync(new() { HttpContext = context }); + + //Assert + Assert.Equal(0, stream.Position); + Assert.Equal(0, stream.Length); + } + + [Fact] + public async Task WriteAsync_Skip_WhenNoFormatter() + { + // Arrange + var formatter = new Mock(); + formatter.Setup(f => f.CanWriteResult(It.IsAny())).Returns(false); + var writer = GetWriter(formatter: formatter.Object); + var stream = new MemoryStream(); + var context = CreateContext(stream); + + //Act + await writer.WriteAsync(new() { HttpContext = context }); + + //Assert + Assert.Equal(0, stream.Position); + Assert.Equal(0, stream.Length); + } + + private static HttpContext CreateContext(Stream body, int statusCode = StatusCodes.Status400BadRequest, EndpointMetadataCollection metadata = null) + { + metadata ??= new EndpointMetadataCollection(new ApiControllerAttribute(), new ControllerAttribute()); + + var context = new DefaultHttpContext() + { + Response = { Body = body, StatusCode = statusCode }, + RequestServices = CreateServices() + }; + context.SetEndpoint(new Endpoint(null, metadata, string.Empty)); + + return context; + } + + private static IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + services.AddTransient(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddSingleton(NullLoggerFactory.Instance); + + return services.BuildServiceProvider(); + } + + private static DefaultApiProblemDetailsWriter GetWriter(ApiBehaviorOptions options = null, IOutputFormatter formatter = null) + { + options ??= new ApiBehaviorOptions(); + formatter ??= new TestFormatter(); + + var mvcOptions = Options.Create(new MvcOptions()); + mvcOptions.Value.OutputFormatters.Add(formatter); + + return new DefaultApiProblemDetailsWriter( + new DefaultOutputFormatterSelector(mvcOptions, NullLoggerFactory.Instance), + new TestHttpResponseStreamWriterFactory(), + new DefaultProblemDetailsFactory(Options.Create(options), null), + Options.Create(options)); + } + + private class TestFormatter : IOutputFormatter + { + public bool CanWriteResult(OutputFormatterCanWriteContext context) => true; + + public Task WriteAsync(OutputFormatterWriteContext context) + { + return context.HttpContext.Response.WriteAsJsonAsync(context.Object); + } + } + +} diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs index 8850174cf95d..be2724e73f40 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs @@ -94,7 +94,7 @@ public async Task ExecuteAsync_WithOneProvidedContentType_FromResponseContentTyp // Arrange var executor = CreateExecutor(); - var httpContext = new DefaultHttpContext(); + var httpContext = GetHttpContext(); var actionContext = new ActionContext() { HttpContext = httpContext }; httpContext.Request.Headers.Accept = "application/xml"; // This will not be used httpContext.Response.ContentType = "application/json"; @@ -258,7 +258,7 @@ public async Task ExecuteAsync_NoFormatterFound_Returns406() var actionContext = new ActionContext() { - HttpContext = new DefaultHttpContext(), + HttpContext = GetHttpContext(), }; var result = new ObjectResult("input"); diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs index 8cfa50c3fbe2..692b1f9decb5 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/ProblemDetalsClientErrorFactoryTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index 2599ea39e358..25a8b28c745c 100644 --- a/src/Mvc/Mvc.slnf +++ b/src/Mvc/Mvc.slnf @@ -148,4 +148,4 @@ "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} \ No newline at end of file +} diff --git a/src/Shared/HttpValidationProblemDetailsJsonConverter.cs b/src/Shared/ProblemDetails/HttpValidationProblemDetailsJsonConverter.cs similarity index 100% rename from src/Shared/HttpValidationProblemDetailsJsonConverter.cs rename to src/Shared/ProblemDetails/HttpValidationProblemDetailsJsonConverter.cs diff --git a/src/Shared/ProblemDetailsDefaults.cs b/src/Shared/ProblemDetails/ProblemDetailsDefaults.cs similarity index 54% rename from src/Shared/ProblemDetailsDefaults.cs rename to src/Shared/ProblemDetails/ProblemDetailsDefaults.cs index 2e13e1bfd1c9..0a4ad2c2c74d 100644 --- a/src/Shared/ProblemDetailsDefaults.cs +++ b/src/Shared/ProblemDetails/ProblemDetailsDefaults.cs @@ -1,10 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Mvc; -namespace Microsoft.AspNetCore.Http.Extensions; +namespace Microsoft.AspNetCore.Http; internal static class ProblemDetailsDefaults { @@ -34,6 +33,12 @@ internal static class ProblemDetailsDefaults "Not Found" ), + [405] = + ( + "https://tools.ietf.org/html/rfc7231#section-6.5.5", + "Method Not Allowed" + ), + [406] = ( "https://tools.ietf.org/html/rfc7231#section-6.5.6", @@ -64,4 +69,30 @@ internal static class ProblemDetailsDefaults "An error occurred while processing your request." ), }; + + public static void Apply(ProblemDetails problemDetails, int? statusCode) + { + // We allow StatusCode to be specified either on ProblemDetails or on the ObjectResult and use it to configure the other. + // This lets users write return Conflict(new Problem("some description")) + // or return Problem("some-problem", 422) and have the response have consistent fields. + if (problemDetails.Status is null) + { + if (statusCode is not null) + { + problemDetails.Status = statusCode; + } + else + { + problemDetails.Status = problemDetails is HttpValidationProblemDetails ? + StatusCodes.Status400BadRequest : + StatusCodes.Status500InternalServerError; + } + } + + if (Defaults.TryGetValue(problemDetails.Status.Value, out var defaults)) + { + problemDetails.Title ??= defaults.Title; + problemDetails.Type ??= defaults.Type; + } + } } diff --git a/src/Shared/ProblemDetailsJsonConverter.cs b/src/Shared/ProblemDetails/ProblemDetailsJsonConverter.cs similarity index 100% rename from src/Shared/ProblemDetailsJsonConverter.cs rename to src/Shared/ProblemDetails/ProblemDetailsJsonConverter.cs diff --git a/src/Tools/Tools.slnf b/src/Tools/Tools.slnf index fd13bb052a1e..422aee5e931c 100644 --- a/src/Tools/Tools.slnf +++ b/src/Tools/Tools.slnf @@ -110,4 +110,4 @@ "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} \ No newline at end of file +}