Provide upgrade response details (#71757)
authorKatya Sokolova <esokolov@microsoft.com>
Tue, 12 Jul 2022 23:59:15 +0000 (01:59 +0200)
committerGitHub <noreply@github.com>
Tue, 12 Jul 2022 23:59:15 +0000 (01:59 +0200)
* Provide Upgrade response details

* fixing tests

* Address review feedback

* Save HttpStatusCode without CollectHttpResponseDetails

* Remove unnesessary skip on test

* Disable ConnectAsync_Failed on browser since CollectHttpResponseDetails is not supported

* Update src/libraries/System.Net.WebSockets.Client/tests/ConnectTest.cs

Co-authored-by: Natalia Kondratyeva <knatalia@microsoft.com>
* Address review feedback

* Revert "Save HttpStatusCode without CollectHttpResponseDetails"

This reverts commit 0713bd8e292b6a76b0b9f297d95e466f11feff3b.

* renove using from ref

* Update test

* Update src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/ClientWebSocket.cs

Co-authored-by: Natalia Kondratyeva <knatalia@microsoft.com>
* fixing Values and Enumerator for HttpResponseHeaders

* fixing Values and Enumerator for HttpResponseHeaders

* Apply suggestions from code review

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
* Update src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/WebSocketHandle.Managed.cs

Co-authored-by: Stephen Toub <stoub@microsoft.com>
* Apply suggestions from code review

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
* Check CollectHttpResponseDetails setter

* disable CA1822 // Mark members as static

Co-authored-by: Natalia Kondratyeva <knatalia@microsoft.com>
Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
src/libraries/System.Net.WebSockets.Client/ref/System.Net.WebSockets.Client.cs
src/libraries/System.Net.WebSockets.Client/src/System.Net.WebSockets.Client.csproj
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/BrowserWebSockets/ClientWebSocketOptions.cs
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/ClientWebSocket.cs
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/ClientWebSocketOptions.cs
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/HttpResponseHeadersReadOnlyCollection.cs [new file with mode: 0644]
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/WebSocketHandle.Browser.cs
src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/WebSocketHandle.Managed.cs
src/libraries/System.Net.WebSockets.Client/tests/ConnectTest.cs

index 15cc579..d7b7a97 100644 (file)
@@ -3,6 +3,7 @@
 // ------------------------------------------------------------------------------
 // Changes to this file must follow the https://aka.ms/api-review process.
 // ------------------------------------------------------------------------------
+
 namespace System.Net.WebSockets
 {
     public sealed partial class ClientWebSocket : System.Net.WebSockets.WebSocket
@@ -10,6 +11,8 @@ namespace System.Net.WebSockets
         public ClientWebSocket() { }
         public override System.Net.WebSockets.WebSocketCloseStatus? CloseStatus { get { throw null; } }
         public override string? CloseStatusDescription { get { throw null; } }
+        public System.Net.HttpStatusCode HttpStatusCode { get { throw null; } }
+        public System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>>? HttpResponseHeaders { get { throw null; } set { } }
         public System.Net.WebSockets.ClientWebSocketOptions Options { get { throw null; } }
         public override System.Net.WebSockets.WebSocketState State { get { throw null; } }
         public override string? SubProtocol { get { throw null; } }
@@ -32,6 +35,8 @@ namespace System.Net.WebSockets
         [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")]
         public System.Net.CookieContainer? Cookies { get { throw null; } set { } }
         [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")]
+        public bool CollectHttpResponseDetails { get { throw null; } set { } }
+        [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")]
         public System.Net.ICredentials? Credentials { get { throw null; } set { } }
         [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")]
         public System.TimeSpan KeepAliveInterval { get { throw null; } set { } }
index f15169b..b7da9d3 100644 (file)
@@ -13,6 +13,7 @@
     <Compile Include="System\Net\WebSockets\ClientWebSocketOptions.cs" Condition="'$(TargetPlatformIdentifier)' != 'Browser'" />
     <Compile Include="$(CommonPath)System\Net\UriScheme.cs" Link="Common\System\Net\UriScheme.cs" />
     <Compile Include="$(CommonPath)System\Net\WebSockets\WebSocketValidate.cs" Link="Common\System\Net\WebSockets\WebSocketValidate.cs" />
+    <Compile Include="System\Net\WebSockets\HttpResponseHeadersReadOnlyCollection.cs" />
   </ItemGroup>
   <ItemGroup Condition="'$(TargetPlatformIdentifier)' != 'Browser'">
     <Compile Include="System\Net\WebSockets\WebSocketHandle.Managed.cs" />
index e01b4fc..59096fc 100644 (file)
@@ -82,6 +82,13 @@ namespace System.Net.WebSockets
             set => throw new PlatformNotSupportedException();
         }
 
+        [UnsupportedOSPlatform("browser")]
+        public bool CollectHttpResponseDetails
+        {
+            get => throw new PlatformNotSupportedException();
+            set => throw new PlatformNotSupportedException();
+        }
+
         #endregion HTTP Settings
 
         #region WebSocket Settings
index 2a32dee..e8a652e 100644 (file)
@@ -1,6 +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 System.Collections.Generic;
 using System.Diagnostics;
 using System.Net.Http;
 using System.Threading;
@@ -51,6 +52,21 @@ namespace System.Net.WebSockets
             }
         }
 
+        public System.Net.HttpStatusCode HttpStatusCode => _innerWebSocket?.HttpStatusCode ?? 0;
+
+        // setter to clean up when not needed anymore
+        public IReadOnlyDictionary<string, IEnumerable<string>>? HttpResponseHeaders
+        {
+            get => _innerWebSocket?.HttpResponseHeaders;
+            set
+            {
+                if (_innerWebSocket != null)
+                {
+                    _innerWebSocket.HttpResponseHeaders = value;
+                }
+            }
+        }
+
         public Task ConnectAsync(Uri uri, CancellationToken cancellationToken)
         {
             return ConnectAsync(uri, null, cancellationToken);
index 5f8027a..463ccf0 100644 (file)
@@ -28,6 +28,7 @@ namespace System.Net.WebSockets
         internal List<string>? _requestedSubProtocols;
         private Version _version = Net.HttpVersion.Version11;
         private HttpVersionPolicy _versionPolicy = HttpVersionPolicy.RequestVersionOrLower;
+        private bool _collectHttpResponseDetails;
 
         internal ClientWebSocketOptions() { } // prevent external instantiation
 
@@ -232,6 +233,17 @@ namespace System.Net.WebSockets
             _buffer = buffer;
         }
 
+        [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("browser")]
+        public bool CollectHttpResponseDetails
+        {
+            get => _collectHttpResponseDetails;
+            set
+            {
+                ThrowIfReadOnly();
+                _collectHttpResponseDetails = value;
+            }
+        }
+
         #endregion WebSocket settings
 
         #region Helpers
diff --git a/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/HttpResponseHeadersReadOnlyCollection.cs b/src/libraries/System.Net.WebSockets.Client/src/System/Net/WebSockets/HttpResponseHeadersReadOnlyCollection.cs
new file mode 100644 (file)
index 0000000..4e874a2
--- /dev/null
@@ -0,0 +1,67 @@
+// 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;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http.Headers;
+
+namespace System.Net.WebSockets
+{
+    internal sealed class HttpResponseHeadersReadOnlyCollection : IReadOnlyDictionary<string, IEnumerable<string>>
+    {
+        private readonly HttpHeadersNonValidated _headers;
+
+        public HttpResponseHeadersReadOnlyCollection(HttpResponseHeaders headers) => _headers = headers.NonValidated;
+
+        public IEnumerable<string> this[string key] => _headers[key];
+
+        public IEnumerable<string> Keys
+        {
+            get
+            {
+                foreach (KeyValuePair<string, HeaderStringValues> header in _headers)
+                {
+                    yield return header.Key;
+                }
+            }
+        }
+
+        public IEnumerable<IEnumerable<string>> Values
+        {
+            get
+            {
+                foreach (KeyValuePair<string, HeaderStringValues> header in _headers)
+                {
+                    yield return header.Value;
+                }
+            }
+        }
+
+        public int Count => _headers.Count;
+
+        public bool ContainsKey(string key) => _headers.Contains(key);
+
+        public IEnumerator<KeyValuePair<string, IEnumerable<string>>> GetEnumerator()
+        {
+            foreach (KeyValuePair<string, HeaderStringValues> header in _headers)
+            {
+                yield return new KeyValuePair<string, IEnumerable<string>>(header.Key, header.Value);
+            }
+        }
+
+        public bool TryGetValue(string key, [MaybeNullWhen(false)] out IEnumerable<string> value)
+        {
+            if (_headers.TryGetValues(key, out HeaderStringValues values))
+            {
+                value = values;
+                return true;
+            }
+
+            value = null;
+            return false;
+        }
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+    }
+}
index 2addc85..f90eb22 100644 (file)
@@ -1,6 +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 System.Collections.Generic;
 using System.Net.Http;
 using System.Threading;
 using System.Threading.Tasks;
@@ -10,6 +11,11 @@ namespace System.Net.WebSockets
     internal sealed class WebSocketHandle
     {
         private WebSocketState _state = WebSocketState.Connecting;
+#pragma warning disable CA1822 // Mark members as static
+        public HttpStatusCode HttpStatusCode => (HttpStatusCode)0;
+#pragma warning restore CA1822 // Mark members as static
+
+        public IReadOnlyDictionary<string, IEnumerable<string>>? HttpResponseHeaders { get; set; }
 
         public WebSocket? WebSocket { get; private set; }
         public WebSocketState State => WebSocket?.State ?? _state;
index 480ea91..d9dd9c1 100644 (file)
@@ -27,6 +27,9 @@ namespace System.Net.WebSockets
 
         public WebSocket? WebSocket { get; private set; }
         public WebSocketState State => WebSocket?.State ?? _state;
+        public HttpStatusCode HttpStatusCode { get; private set; }
+
+        public IReadOnlyDictionary<string, IEnumerable<string>>? HttpResponseHeaders { get; set; }
 
         public static ClientWebSocketOptions CreateDefaultOptions() => new ClientWebSocketOptions() { Proxy = DefaultWebProxy.Instance };
 
@@ -48,6 +51,8 @@ namespace System.Net.WebSockets
             invoker ??= new HttpMessageInvoker(SetupHandler(options, out disposeHandler));
             HttpResponseMessage? response = null;
 
+            bool disposeResponse = false;
+
             bool tryDowngrade = false;
             try
             {
@@ -187,7 +192,7 @@ namespace System.Net.WebSockets
                 }
 
                 Abort();
-                response?.Dispose();
+                disposeResponse = true;
 
                 if (exc is WebSocketException ||
                     (exc is OperationCanceledException && cancellationToken.IsCancellationRequested))
@@ -199,6 +204,20 @@ namespace System.Net.WebSockets
             }
             finally
             {
+                if (response is not null)
+                {
+                    if (options.CollectHttpResponseDetails)
+                    {
+                        HttpStatusCode = response.StatusCode;
+                        HttpResponseHeaders = new HttpResponseHeadersReadOnlyCollection(response.Headers);
+                    }
+
+                    if (disposeResponse)
+                    {
+                        response.Dispose();
+                    }
+                }
+
                 // Disposing the handler will not affect any active stream wrapped in the WebSocket.
                 if (disposeHandler)
                 {
index 31c375a..416fd02 100644 (file)
@@ -3,6 +3,7 @@
 
 using System.Collections.Generic;
 using System.IO;
+using System.Linq;
 using System.Net.Test.Common;
 using System.Threading;
 using System.Threading.Tasks;
@@ -313,5 +314,71 @@ namespace System.Net.WebSockets.Client.Tests
                 catch (IOException) { }
             }, new LoopbackServer.Options { WebSocketEndpoint = true });
         }
+
+        [ConditionalFact(nameof(WebSocketsSupported))]
+        [ActiveIssue("https://github.com/dotnet/runtime/issues/34690", TestPlatforms.Windows, TargetFrameworkMonikers.Netcoreapp, TestRuntimes.Mono)]
+        [SkipOnPlatform(TestPlatforms.Browser, "CollectHttpResponseDetails not supported on Browser")]
+        public async Task ConnectAsync_HttpResponseDetailsCollectedOnFailure()
+        {
+            await LoopbackServer.CreateClientAndServerAsync(async uri =>
+            {
+                using (var clientWebSocket = new ClientWebSocket())
+                using (var cts = new CancellationTokenSource(TimeOutMilliseconds))
+                {
+                    clientWebSocket.Options.CollectHttpResponseDetails = true;
+                    Task t = clientWebSocket.ConnectAsync(uri, cts.Token);
+                    await Assert.ThrowsAnyAsync<WebSocketException>(() => t);
+
+                    Assert.Equal(HttpStatusCode.Unauthorized, clientWebSocket.HttpStatusCode);
+                    Assert.NotEmpty(clientWebSocket.HttpResponseHeaders);
+                }
+            }, server => server.AcceptConnectionSendResponseAndCloseAsync(HttpStatusCode.Unauthorized), new LoopbackServer.Options { WebSocketEndpoint = true });
+        }
+
+        [ConditionalFact(nameof(WebSocketsSupported))]
+        [ActiveIssue("https://github.com/dotnet/runtime/issues/34690", TestPlatforms.Windows, TargetFrameworkMonikers.Netcoreapp, TestRuntimes.Mono)]
+        [SkipOnPlatform(TestPlatforms.Browser, "CollectHttpResponseDetails not supported on Browser")]
+        public async Task ConnectAsync_HttpResponseDetailsCollectedOnFailure_CustomHeader()
+        {
+            await LoopbackServer.CreateClientAndServerAsync(async uri =>
+            {
+                using (var clientWebSocket = new ClientWebSocket())
+                using (var cts = new CancellationTokenSource(TimeOutMilliseconds))
+                {
+                    clientWebSocket.Options.CollectHttpResponseDetails = true;
+                    Task t = clientWebSocket.ConnectAsync(uri, cts.Token);
+                    await Assert.ThrowsAnyAsync<WebSocketException>(() => t);
+
+                    Assert.Equal(HttpStatusCode.Unauthorized, clientWebSocket.HttpStatusCode);
+                    Assert.NotEmpty(clientWebSocket.HttpResponseHeaders);
+                    Assert.Contains("X-CustomHeader1", clientWebSocket.HttpResponseHeaders);
+                    Assert.Contains("X-CustomHeader2", clientWebSocket.HttpResponseHeaders);
+                    Assert.NotNull(clientWebSocket.HttpResponseHeaders.Values);
+                }
+            }, server => server.AcceptConnectionSendResponseAndCloseAsync(HttpStatusCode.Unauthorized, "X-CustomHeader1: Value1\r\nX-CustomHeader2: Value2\r\n"), new LoopbackServer.Options { WebSocketEndpoint = true });
+        }
+
+        [ConditionalFact(nameof(WebSocketsSupported))]
+        [ActiveIssue("https://github.com/dotnet/runtime/issues/34690", TestPlatforms.Windows, TargetFrameworkMonikers.Netcoreapp, TestRuntimes.Mono)]
+        [SkipOnPlatform(TestPlatforms.Browser, "CollectHttpResponseDetails not supported on Browser")]
+        public async Task ConnectAsync_HttpResponseDetailsCollectedOnSuccess_Extentions()
+        {
+            await LoopbackServer.CreateClientAndServerAsync(async uri =>
+            {
+                using (var clientWebSocket = new ClientWebSocket())
+                using (var cts = new CancellationTokenSource(TimeOutMilliseconds))
+                {
+                    clientWebSocket.Options.CollectHttpResponseDetails = true;
+                    await clientWebSocket.ConnectAsync(uri, cts.Token);
+
+                    Assert.Equal(HttpStatusCode.SwitchingProtocols, clientWebSocket.HttpStatusCode);
+                    Assert.NotEmpty(clientWebSocket.HttpResponseHeaders);
+                    Assert.Contains("Sec-WebSocket-Extensions", clientWebSocket.HttpResponseHeaders);
+                }
+            }, server => server.AcceptConnectionAsync(async connection =>
+            {
+                Dictionary<string, string> headers = await LoopbackHelper.WebSocketHandshakeAsync(connection, "X-CustomHeader1");
+            }), new LoopbackServer.Options { WebSocketEndpoint = true });
+        }
     }
 }