Просмотр исходного кода

HttpContext debugger display tweaks and fixes (#48321)

James Newton-King 2 лет назад
Родитель
Сommit
3d55a2bcb1

+ 7 - 4
src/Extensions/Features/src/FeatureCollection.cs

@@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Http.Features;
 /// <summary>
 /// Default implementation for <see cref="IFeatureCollection"/>.
 /// </summary>
-[DebuggerDisplay("Count = {_features?.Count ?? 0}")]
+[DebuggerDisplay("Count = {GetCount()}")]
 [DebuggerTypeProxy(typeof(FeatureCollectionDebugView))]
 public class FeatureCollection : IFeatureCollection
 {
@@ -140,6 +140,9 @@ public class FeatureCollection : IFeatureCollection
         this[typeof(TFeature)] = instance;
     }
 
+    // Used by the debugger. Count over enumerable is required to get the correct value.
+    private int GetCount() => this.Count();
+
     private sealed class KeyComparer : IEqualityComparer<KeyValuePair<Type, object>>
     {
         public bool Equals(KeyValuePair<Type, object> x, KeyValuePair<Type, object> y)
@@ -153,11 +156,11 @@ public class FeatureCollection : IFeatureCollection
         }
     }
 
-    private sealed class FeatureCollectionDebugView(FeatureCollection collection)
+    private sealed class FeatureCollectionDebugView(FeatureCollection features)
     {
-        private readonly FeatureCollection _collection = collection;
+        private readonly FeatureCollection _features = features;
 
         [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
-        public KeyValuePair<string, object>[] Items => _collection.Select(pair => new KeyValuePair<string, object>(pair.Key.FullName ?? string.Empty, pair.Value)).ToArray();
+        public KeyValuePair<string, object>[] Items => _features.Select(pair => new KeyValuePair<string, object>(pair.Key.FullName ?? string.Empty, pair.Value)).ToArray();
     }
 }

+ 14 - 3
src/Http/Http.Abstractions/src/HttpContext.cs

@@ -2,8 +2,10 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Diagnostics;
+using System.Linq;
 using System.Security.Claims;
 using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Shared;
 
 namespace Microsoft.AspNetCore.Http;
 
@@ -77,8 +79,7 @@ public abstract class HttpContext
 
     private string DebuggerToString()
     {
-        return $"{Request.Method} {Request.Path.Value} {Request.ContentType}"
-            + $" StatusCode = {Response.StatusCode} {Response.ContentType}";
+        return HttpContextDebugFormatter.ContextToString(this, reasonPhrase: null);
     }
 
     private sealed class HttpContextDebugView(HttpContext context)
@@ -86,9 +87,10 @@ public abstract class HttpContext
         private readonly HttpContext _context = context;
 
         // Hide server specific implementations, they combine IFeatureCollection and many feature interfaces.
-        public IFeatureCollection Features => _context.Features as FeatureCollection ?? new FeatureCollection(_context.Features);
+        public HttpContextFeatureDebugView Features => new HttpContextFeatureDebugView(_context.Features);
         public HttpRequest Request => _context.Request;
         public HttpResponse Response => _context.Response;
+        public Endpoint? Endpoint => _context.GetEndpoint();
         public ConnectionInfo Connection => _context.Connection;
         public WebSocketManager WebSockets => _context.WebSockets;
         public ClaimsPrincipal User => _context.User;
@@ -98,4 +100,13 @@ public abstract class HttpContext
         // The normal session property throws if accessed before/without the session middleware.
         public ISession? Session => _context.Features.Get<ISessionFeature>()?.Session;
     }
+
+    [DebuggerDisplay("Count = {Items.Length}")]
+    private sealed class HttpContextFeatureDebugView(IFeatureCollection features)
+    {
+        private readonly IFeatureCollection _features = features;
+
+        [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
+        public KeyValuePair<string, object>[] Items => _features.Select(pair => new KeyValuePair<string, object>(pair.Key.FullName ?? string.Empty, pair.Value)).ToArray();
+    }
 }

+ 2 - 3
src/Http/Http.Abstractions/src/HttpRequest.cs

@@ -2,10 +2,10 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Diagnostics;
-using System.Globalization;
 using System.IO.Pipelines;
 using Microsoft.AspNetCore.Http.Features;
 using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Shared;
 
 namespace Microsoft.AspNetCore.Http;
 
@@ -154,8 +154,7 @@ public abstract class HttpRequest
 
     private string DebuggerToString()
     {
-        return $"{Protocol} {Method} {Scheme}://{Host.Value}{PathBase.Value}{Path.Value}{QueryString.Value} {ContentType}"
-            + $" Length = {ContentLength?.ToString(CultureInfo.InvariantCulture) ?? "(null)"}";
+        return HttpContextDebugFormatter.RequestToString(this);
     }
 
     private sealed class HttpRequestDebugView(HttpRequest request)

+ 3 - 4
src/Http/Http.Abstractions/src/HttpResponse.cs

@@ -3,8 +3,8 @@
 
 using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
 using System.IO.Pipelines;
+using Microsoft.AspNetCore.Shared;
 
 namespace Microsoft.AspNetCore.Http;
 
@@ -154,10 +154,9 @@ public abstract class HttpResponse
     /// <returns></returns>
     public virtual Task CompleteAsync() { throw new NotImplementedException(); }
 
-    private string DebuggerToString()
+    internal string DebuggerToString()
     {
-        return $"StatusCode = {StatusCode}, HasStarted = {HasStarted},"
-            + $" Length = {ContentLength?.ToString(CultureInfo.InvariantCulture) ?? "(null)"} {ContentType}";
+        return HttpContextDebugFormatter.ResponseToString(this, reasonPhrase: null);
     }
 
     private sealed class HttpResponseDebugView(HttpResponse response)

+ 1 - 0
src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj

@@ -27,6 +27,7 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
     <Compile Include="$(SharedSourceRoot)\UrlDecoder\UrlDecoder.cs" Link="UrlDecoder.cs" />
     <Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" />
     <Compile Include="$(SharedSourceRoot)Reroute.cs" />
+    <Compile Include="$(SharedSourceRoot)Debugger\HttpContextDebugFormatter.cs" LinkBase="Shared" />
   </ItemGroup>
 
   <ItemGroup>

+ 1 - 1
src/Http/Http.Abstractions/src/WebSocketManager.cs

@@ -60,6 +60,6 @@ public abstract class WebSocketManager
         private readonly WebSocketManager _manager = manager;
 
         public bool IsWebSocketRequest => _manager.IsWebSocketRequest;
-        public IList<string> WebSocketRequestedProtocols => _manager.WebSocketRequestedProtocols;
+        public IList<string> WebSocketRequestedProtocols => new List<string>(_manager.WebSocketRequestedProtocols);
     }
 }

+ 11 - 0
src/Http/Http/src/DefaultHttpContext.cs

@@ -2,10 +2,13 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.ComponentModel;
+using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Security.Claims;
 using Microsoft.AspNetCore.Http.Features;
 using Microsoft.AspNetCore.Http.Features.Authentication;
+using Microsoft.AspNetCore.Shared;
+using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Http;
@@ -13,6 +16,8 @@ namespace Microsoft.AspNetCore.Http;
 /// <summary>
 /// Represents an implementation of the HTTP Context class.
 /// </summary>
+// DebuggerDisplayAttribute is inherited but we're replacing it on this implementation to include reason phrase.
+[DebuggerDisplay("{DebuggerToString(),nq}")]
 public sealed class DefaultHttpContext : HttpContext
 {
     // The initial size of the feature collection when using the default constructor; based on number of common features
@@ -236,6 +241,12 @@ public sealed class DefaultHttpContext : HttpContext
         throw new ObjectDisposedException(nameof(HttpContext), $"Request has finished and {nameof(HttpContext)} disposed.");
     }
 
+    private string DebuggerToString()
+    {
+        // DebuggerToString is also on this type because this project has access to ReasonPhrases.
+        return HttpContextDebugFormatter.ContextToString(this, ReasonPhrases.GetReasonPhrase(Response.StatusCode));
+    }
+
     struct FeatureInterfaces
     {
         public IItemsFeature? Items;

+ 11 - 0
src/Http/Http/src/Internal/DefaultHttpResponse.cs

@@ -1,11 +1,16 @@
 // 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.IO.Pipelines;
 using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Shared;
+using Microsoft.AspNetCore.WebUtilities;
 
 namespace Microsoft.AspNetCore.Http;
 
+// DebuggerDisplayAttribute is inherited but we're replacing it on this implementation to include reason phrase.
+[DebuggerDisplay("{DebuggerToString(),nq}")]
 internal sealed class DefaultHttpResponse : HttpResponse
 {
     // Lambdas hoisted to static readonly fields to improve inlining https://github.com/dotnet/roslyn/issues/13624
@@ -159,6 +164,12 @@ internal sealed class DefaultHttpResponse : HttpResponse
 
     public override Task CompleteAsync() => HttpResponseBodyFeature.CompleteAsync();
 
+    internal string DebuggerToString()
+    {
+        // DebuggerToString is also on this type because this project has access to ReasonPhrases.
+        return HttpContextDebugFormatter.ResponseToString(this, ReasonPhrases.GetReasonPhrase(StatusCode));
+    }
+
     struct FeatureInterfaces
     {
         public IHttpResponseFeature? Response;

+ 1 - 0
src/Http/Http/src/Microsoft.AspNetCore.Http.csproj

@@ -19,6 +19,7 @@
     <Compile Include="..\..\Shared\CookieHeaderParserShared.cs" Link="Internal\CookieHeaderParserShared.cs" />
     <Compile Include="$(SharedSourceRoot)HttpRuleParser.cs" LinkBase="Shared" />
     <Compile Include="$(SharedSourceRoot)HttpParseResult.cs" LinkBase="Shared" />
+    <Compile Include="$(SharedSourceRoot)Debugger\HttpContextDebugFormatter.cs" LinkBase="Shared" />
     <Compile Include="..\..\WebUtilities\src\AspNetCoreTempDirectory.cs" LinkBase="Internal" />
     <Compile Include="..\..\..\Shared\Dictionary\AdaptiveCapacityDictionary.cs" LinkBase="Internal" />
   </ItemGroup>

+ 58 - 0
src/Shared/Debugger/HttpContextDebugFormatter.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.
+
+using System.Globalization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Microsoft.AspNetCore.Shared;
+
+internal static class HttpContextDebugFormatter
+{
+    public static string ResponseToString(HttpResponse response, string? reasonPhrase)
+    {
+        var text = response.StatusCode.ToString(CultureInfo.InvariantCulture);
+        var resolvedReasonPhrase = ResolveReasonPhrase(response, reasonPhrase);
+        if (!string.IsNullOrEmpty(resolvedReasonPhrase))
+        {
+            text += $" {resolvedReasonPhrase}";
+        }
+        if (!string.IsNullOrEmpty(response.ContentType))
+        {
+            text += $" {response.ContentType}";
+        }
+        return text;
+    }
+
+    private static string? ResolveReasonPhrase(HttpResponse response, string? reasonPhrase)
+    {
+        return response.HttpContext.Features.Get<IHttpResponseFeature>()?.ReasonPhrase ?? reasonPhrase;
+    }
+
+    public static string RequestToString(HttpRequest request)
+    {
+        var text = $"{request.Method} {GetRequestUrl(request, includeQueryString: true)} {request.Protocol}";
+        if (!string.IsNullOrEmpty(request.ContentType))
+        {
+            text += $" {request.ContentType}";
+        }
+        return text;
+    }
+
+    public static string ContextToString(HttpContext context, string? reasonPhrase)
+    {
+        var text = $"{context.Request.Method} {GetRequestUrl(context.Request, includeQueryString: false)} {context.Response.StatusCode}";
+        var resolvedReasonPhrase = ResolveReasonPhrase(context.Response, reasonPhrase);
+        if (!string.IsNullOrEmpty(resolvedReasonPhrase))
+        {
+            text += $" {resolvedReasonPhrase}";
+        }
+
+        return text;
+    }
+
+    private static string GetRequestUrl(HttpRequest request, bool includeQueryString)
+    {
+        return $"{request.Scheme}://{request.Host.Value}{request.PathBase.Value}{request.Path.Value}{(includeQueryString ? request.QueryString.Value : string.Empty)}";
+    }
+}