From b5128a7efd20b09bf8268722c2bada65b507b7da Mon Sep 17 00:00:00 2001 From: Robin Sue Date: Wed, 23 May 2018 11:16:27 +0200 Subject: [PATCH] Add support for sending and receiving arbitrary HttpContent, refs #479 (#815) * Add support for zero copy byte array marshalling * Add support for sending arbitrary HttpContent, refs #479 * Fix unit test to set ContentType correctly * Add support for receiving binary data * Compare header case insensitive * Add unit test for binary http requests --- .../src/Platform/Mono/MonoPlatform.ts | 6 ++ .../src/Platform/Platform.ts | 2 + .../src/Services/Http.ts | 64 ++++++++++++----- .../Http/BrowserHttpMessageHandler.cs | 71 ++++++++++++------- .../Tests/BinaryHttpClientTest.cs | 67 +++++++++++++++++ .../Tests/HttpClientTest.cs | 2 +- .../BinaryHttpRequestsComponent.cshtml | 71 +++++++++++++++++++ .../HttpRequestsComponent.cshtml | 9 +++ test/testapps/BasicTestApp/wwwroot/index.html | 1 + .../TestServer/Controllers/DataController.cs | 46 ++++++++++++ 10 files changed, 294 insertions(+), 45 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BinaryHttpClientTest.cs create mode 100644 test/testapps/BasicTestApp/HttpClientTest/BinaryHttpRequestsComponent.cshtml create mode 100644 test/testapps/TestServer/Controllers/DataController.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts index c4d59e4b5a..986007811b 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts @@ -91,6 +91,12 @@ export const monoPlatform: Platform = { return mono_string(jsString); }, + toUint8Array: function toUint8Array(array: System_Array): Uint8Array { + const dataPtr = getArrayDataPointer(array); + const length = Module.getValue(dataPtr, 'i32'); + return new Uint8Array(Module.HEAPU8.buffer, dataPtr + 4, length); + }, + getArrayLength: function getArrayLength(array: System_Array): number { return Module.getValue(getArrayDataPointer(array), 'i32'); }, diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Platform.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Platform.ts index fd1b29d727..690b71f758 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Platform.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Platform/Platform.ts @@ -8,6 +8,8 @@ toJavaScriptString(dotNetString: System_String): string; toDotNetString(javaScriptString: string): System_String; + toUint8Array(array: System_Array): Uint8Array; + getArrayLength(array: System_Array): number; getArrayEntryPtr(array: System_Array, index: number, itemSize: number): TPtr; diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts index f9e950c61f..350d96c60c 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Services/Http.ts @@ -1,50 +1,71 @@ import { registerFunction } from '../Interop/RegisteredFunction'; import { platform } from '../Environment'; -import { MethodHandle, System_String } from '../Platform/Platform'; +import { MethodHandle, System_String, System_Array } from '../Platform/Platform'; const httpClientAssembly = 'Microsoft.AspNetCore.Blazor.Browser'; const httpClientNamespace = `${httpClientAssembly}.Http`; const httpClientTypeName = 'BrowserHttpMessageHandler'; const httpClientFullTypeName = `${httpClientNamespace}.${httpClientTypeName}`; let receiveResponseMethod: MethodHandle; +let allocateArrayMethod: MethodHandle; -registerFunction(`${httpClientFullTypeName}.Send`, (id: number, method: string, requestUri: string, body: string | null, headersJson: string | null, fetchArgs: RequestInit | null) => { - sendAsync(id, method, requestUri, body, headersJson, fetchArgs); +registerFunction(`${httpClientFullTypeName}.Send`, (id: number, body: System_Array, jsonFetchArgs: System_String) => { + sendAsync(id, body, jsonFetchArgs); }); -async function sendAsync(id: number, method: string, requestUri: string, body: string | null, headersJson: string | null, fetchArgs: RequestInit | null) { +async function sendAsync(id: number, body: System_Array, jsonFetchArgs: System_String) { let response: Response; - let responseText: string; + let responseData: ArrayBuffer; - const requestInit: RequestInit = fetchArgs || {}; - requestInit.method = method; - requestInit.body = body || undefined; + const fetchOptions: FetchOptions = JSON.parse(platform.toJavaScriptString(jsonFetchArgs)); + const requestInit: RequestInit = Object.assign(fetchOptions.requestInit, fetchOptions.requestInitOverrides); + + if (body) { + requestInit.body = platform.toUint8Array(body); + } try { - requestInit.headers = headersJson ? (JSON.parse(headersJson) as string[][]) : undefined; - - response = await fetch(requestUri, requestInit); - responseText = await response.text(); + response = await fetch(fetchOptions.requestUri, requestInit); + responseData = await response.arrayBuffer(); } catch (ex) { dispatchErrorResponse(id, ex.toString()); return; } - dispatchSuccessResponse(id, response, responseText); + dispatchSuccessResponse(id, response, responseData); } -function dispatchSuccessResponse(id: number, response: Response, responseText: string) { +function dispatchSuccessResponse(id: number, response: Response, responseData: ArrayBuffer) { const responseDescriptor: ResponseDescriptor = { statusCode: response.status, + statusText: response.statusText, headers: [] }; response.headers.forEach((value, name) => { responseDescriptor.headers.push([name, value]); }); + if (!allocateArrayMethod) { + allocateArrayMethod = platform.findMethod( + httpClientAssembly, + httpClientNamespace, + httpClientTypeName, + 'AllocateArray' + ); + } + + // allocate a managed byte[] of the right size + const dotNetArray = platform.callMethod(allocateArrayMethod, null, [platform.toDotNetString(responseData.byteLength.toString())]) as System_Array; + + // get an Uint8Array view of it + const array = platform.toUint8Array(dotNetArray); + + // copy the responseData to our managed byte[] + array.set(new Uint8Array(responseData)); + dispatchResponse( id, platform.toDotNetString(JSON.stringify(responseDescriptor)), - platform.toDotNetString(responseText), // TODO: Consider how to handle non-string responses + dotNetArray, /* errorMessage */ null ); } @@ -58,7 +79,7 @@ function dispatchErrorResponse(id: number, errorMessage: string) { ); } -function dispatchResponse(id: number, responseDescriptor: System_String | null, responseText: System_String | null, errorMessage: System_String | null) { +function dispatchResponse(id: number, responseDescriptor: System_String | null, responseData: System_Array | null, errorMessage: System_String | null) { if (!receiveResponseMethod) { receiveResponseMethod = platform.findMethod( httpClientAssembly, @@ -71,16 +92,23 @@ function dispatchResponse(id: number, responseDescriptor: System_String | null, platform.callMethod(receiveResponseMethod, null, [ platform.toDotNetString(id.toString()), responseDescriptor, - responseText, + responseData, errorMessage, ]); } -// Keep this in sync with the .NET equivalent in HttpClient.cs +// Keep these in sync with the .NET equivalent in BrowserHttpMessageHandler.cs +interface FetchOptions { + requestUri: string; + requestInit: RequestInit; + requestInitOverrides: RequestInit; +} + interface ResponseDescriptor { // We don't have BodyText in here because if we did, then in the JSON-response case (which // is the most common case), we'd be double-encoding it, since the entire ResponseDescriptor // also gets JSON encoded. It would work but is twice the amount of string processing. statusCode: number; + statusText: string; headers: string[][]; } diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs index 9511a1a2c2..980b90fba1 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Http/BrowserHttpMessageHandler.cs @@ -49,38 +49,39 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http _pendingRequests.Add(id, tcs); } - request.Properties.TryGetValue(FetchArgs, out var fetchArgs); + var options = new FetchOptions(); + if (request.Properties.TryGetValue(FetchArgs, out var fetchArgs)) + { + options.RequestInitOverrides = fetchArgs; + } - RegisteredFunction.Invoke( + options.RequestInit = new RequestInit + { + Credentials = GetDefaultCredentialsString(), + Headers = GetHeadersAsStringArray(request), + Method = request.Method.Method + }; + + options.RequestUri = request.RequestUri.ToString(); + + RegisteredFunction.InvokeUnmarshalled( $"{typeof(BrowserHttpMessageHandler).FullName}.Send", id, - request.Method.Method, - request.RequestUri, - request.Content == null ? null : await GetContentAsString(request.Content), - SerializeHeadersAsJson(request), - fetchArgs ?? CreateDefaultFetchArgs()); + request.Content == null ? null : await request.Content.ReadAsByteArrayAsync(), + JsonUtil.Serialize(options)); return await tcs.Task; } - private string SerializeHeadersAsJson(HttpRequestMessage request) - => JsonUtil.Serialize( - (from header in request.Headers.Concat(request.Content?.Headers ?? Enumerable.Empty>>()) - from headerValue in header.Value // There can be more than one value for each name - select new[] { header.Key, headerValue }).ToList() - ); - - private static async Task GetContentAsString(HttpContent content) - => content is StringContent stringContent - ? await stringContent.ReadAsStringAsync() - : throw new InvalidOperationException($"Currently, {typeof(HttpClient).FullName} " + - $"only supports contents of type {nameof(StringContent)}, but you supplied " + - $"{content.GetType().FullName}."); + private string[][] GetHeadersAsStringArray(HttpRequestMessage request) + => (from header in request.Headers.Concat(request.Content?.Headers ?? Enumerable.Empty>>()) + from headerValue in header.Value // There can be more than one value for each name + select new[] { header.Key, headerValue }).ToArray(); private static void ReceiveResponse( string id, string responseDescriptorJson, - string responseBodyText, + byte[] responseBodyData, string errorText) { TaskCompletionSource tcs; @@ -98,16 +99,18 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http else { var responseDescriptor = JsonUtil.Deserialize(responseDescriptorJson); - var responseContent = responseBodyText == null ? null : new StringContent(responseBodyText); + var responseContent = responseBodyData == null ? null : new ByteArrayContent(responseBodyData); var responseMessage = responseDescriptor.ToResponseMessage(responseContent); tcs.SetResult(responseMessage); } } - private static object CreateDefaultFetchArgs() - => new { credentials = GetDefaultCredentialsString() }; + private static byte[] AllocateArray(string length) + { + return new byte[int.Parse(length)]; + } - private static object GetDefaultCredentialsString() + private static string GetDefaultCredentialsString() { // See https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials for // standard values and meanings @@ -124,17 +127,33 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http } } - // Keep in sync with TypeScript class in Http.ts + // Keep these in sync with TypeScript class in Http.ts + private class FetchOptions + { + public string RequestUri { get; set; } + public RequestInit RequestInit { get; set; } + public object RequestInitOverrides { get; set; } + } + + private class RequestInit + { + public string Credentials { get; set; } + public string[][] Headers { get; set; } + public string Method { get; set; } + } + private class ResponseDescriptor { #pragma warning disable 0649 public int StatusCode { get; set; } + public string StatusText { get; set; } public string[][] Headers { get; set; } #pragma warning restore 0649 public HttpResponseMessage ToResponseMessage(HttpContent content) { var result = new HttpResponseMessage((HttpStatusCode)StatusCode); + result.ReasonPhrase = StatusText; result.Content = content; var headers = result.Headers; var contentHeaders = result.Content?.Headers; diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BinaryHttpClientTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BinaryHttpClientTest.cs new file mode 100644 index 0000000000..e5c302adf6 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/BinaryHttpClientTest.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BasicTestApp.HttpClientTest; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure.ServerFixtures; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using System; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests +{ + public class BinaryHttpClientTest : BasicTestAppTestBase, IClassFixture + { + readonly ServerFixture _apiServerFixture; + readonly IWebElement _appElement; + IWebElement _responseStatus; + IWebElement _responseStatusText; + IWebElement _testOutcome; + + public BinaryHttpClientTest( + BrowserFixture browserFixture, + DevHostServerFixture devHostServerFixture, + AspNetSiteServerFixture apiServerFixture, + ITestOutputHelper output) + : base(browserFixture, devHostServerFixture, output) + { + apiServerFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; + _apiServerFixture = apiServerFixture; + + Navigate(ServerPathBase, noReload: true); + _appElement = MountTestComponent(); + } + + [Fact] + public void CanSendAndReceiveBytes() + { + IssueRequest("/api/data"); + Assert.Equal("OK", _responseStatus.Text); + Assert.Equal("OK", _responseStatusText.Text); + Assert.Equal("", _testOutcome.Text); + } + + private void IssueRequest(string relativeUri) + { + var targetUri = new Uri(_apiServerFixture.RootUri, relativeUri); + SetValue("request-uri", targetUri.AbsoluteUri); + + _appElement.FindElement(By.Id("send-request")).Click(); + + new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until( + driver => driver.FindElement(By.Id("response-status")) != null); + _responseStatus = _appElement.FindElement(By.Id("response-status")); + _responseStatusText = _appElement.FindElement(By.Id("response-status-text")); + _testOutcome = _appElement.FindElement(By.Id("test-outcome")); + } + + private void SetValue(string elementId, string value) + { + var element = Browser.FindElement(By.Id(elementId)); + element.Clear(); + element.SendKeys(value); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs index 4d6101ebb4..604d717230 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/HttpClientTest.cs @@ -95,7 +95,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests AddRequestHeader("Content-Type", "application/json"); IssueRequest("PUT", "/api/person", "{\"Name\": \"Bert\", \"Id\": 123}"); Assert.Equal("OK", _responseStatus.Text); - Assert.Contains("Content-Type: application/json", _responseHeaders.Text); + Assert.Contains("Content-Type: application/json", _responseHeaders.Text, StringComparison.OrdinalIgnoreCase); Assert.Equal("{\"id\":123,\"name\":\"Bert\"}", _responseBody.Text); } diff --git a/test/testapps/BasicTestApp/HttpClientTest/BinaryHttpRequestsComponent.cshtml b/test/testapps/BasicTestApp/HttpClientTest/BinaryHttpRequestsComponent.cshtml new file mode 100644 index 0000000000..3ffbd88850 --- /dev/null +++ b/test/testapps/BasicTestApp/HttpClientTest/BinaryHttpRequestsComponent.cshtml @@ -0,0 +1,71 @@ +@using System.Net +@using System.Net.Http +@inject HttpClient Http + +

Binary HTTP request tester

+ +

+

URI:
+ +

+ + + +@if (responseStatusCode.HasValue) +{ +

Response

+

Status:
@responseStatusCode

+

StatusText:
@responseStatusText

+} + +@testOutcome + +@functions { + string uri = ""; + HttpStatusCode? responseStatusCode; + string responseStatusText; + string testOutcome = ""; + + async Task DoRequest() + { + responseStatusCode = null; + responseStatusText = null; + testOutcome = null; + + try + { + var bytes = await Http.GetByteArrayAsync(uri); + if (bytes.Length != 256) + { + testOutcome = "Expected 256 bytes but got " + bytes.Length.ToString(); + return; + } + + var reversedBytes = bytes.ToArray(); + Array.Reverse(reversedBytes); + + var response = await Http.PostAsync(uri, new ByteArrayContent(reversedBytes)); + responseStatusCode = response.StatusCode; + responseStatusText = response.ReasonPhrase; + var doubleReversed = await response.Content.ReadAsByteArrayAsync(); + + for (int i = 0; i <= byte.MaxValue; i++) + { + if (doubleReversed[i] != (byte)i) + { + testOutcome = $"Expected byte at index {i} to have value {i} but actually was {doubleReversed[i]}"; + return; + } + } + } + catch (Exception ex) + { + if (ex is AggregateException) + { + ex = ex.InnerException; + } + responseStatusCode = HttpStatusCode.SeeOther; + testOutcome = ex.Message + Environment.NewLine + ex.StackTrace; + } + } +} diff --git a/test/testapps/BasicTestApp/HttpClientTest/HttpRequestsComponent.cshtml b/test/testapps/BasicTestApp/HttpClientTest/HttpRequestsComponent.cshtml index f49ae4d75a..0bf537df1c 100644 --- a/test/testapps/BasicTestApp/HttpClientTest/HttpRequestsComponent.cshtml +++ b/test/testapps/BasicTestApp/HttpClientTest/HttpRequestsComponent.cshtml @@ -88,6 +88,15 @@ foreach (var header in requestHeaders) { + // StringContent automatically adds its own Content-Type header with default value "text/plain" + // If the developer is trying to specify a content type explicitly, we need to replace the default value, + // rather than adding a second Content-Type header. + if (header.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) && requestMessage.Content != null) + { + requestMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(header.Value); + continue; + } + if (!requestMessage.Headers.TryAddWithoutValidation(header.Name, header.Value)) { requestMessage.Content?.Headers.TryAddWithoutValidation(header.Name, header.Value); diff --git a/test/testapps/BasicTestApp/wwwroot/index.html b/test/testapps/BasicTestApp/wwwroot/index.html index d4f052e4ea..9ac33fb8e0 100644 --- a/test/testapps/BasicTestApp/wwwroot/index.html +++ b/test/testapps/BasicTestApp/wwwroot/index.html @@ -26,6 +26,7 @@ + diff --git a/test/testapps/TestServer/Controllers/DataController.cs b/test/testapps/TestServer/Controllers/DataController.cs new file mode 100644 index 0000000000..1ae4fd6198 --- /dev/null +++ b/test/testapps/TestServer/Controllers/DataController.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; + +namespace TestServer.Controllers +{ + [EnableCors("AllowAll")] + [Route("api/[controller]")] + public class DataController : Controller + { + // GET api/data + [HttpGet] + public FileContentResult Get() + { + var bytes = new byte[byte.MaxValue + 1]; + for (int i = 0; i <= byte.MaxValue; i++) + { + bytes[i] = (byte)i; + } + + return File(bytes, "application/octet-stream"); + } + + // POST api/data + [HttpPost] + public async Task PostAsync() + { + var ms = new MemoryStream(); + await Request.Body.CopyToAsync(ms); + var bytes = ms.ToArray(); + Array.Reverse(bytes); + + for (int i = 0; i <= byte.MaxValue; i++) + { + if (bytes[i] != (byte)i) + { + return BadRequest(); + } + } + + return File(bytes, "application/octet-stream"); + } + } +}