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
This commit is contained in:
Robin Sue 2018-05-23 11:16:27 +02:00 committed by Steve Sanderson
parent e801707706
commit b5128a7efd
10 changed files with 294 additions and 45 deletions

View File

@ -91,6 +91,12 @@ export const monoPlatform: Platform = {
return mono_string(jsString);
},
toUint8Array: function toUint8Array(array: System_Array<any>): 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<any>): number {
return Module.getValue(getArrayDataPointer(array), 'i32');
},

View File

@ -8,6 +8,8 @@
toJavaScriptString(dotNetString: System_String): string;
toDotNetString(javaScriptString: string): System_String;
toUint8Array(array: System_Array<any>): Uint8Array;
getArrayLength(array: System_Array<any>): number;
getArrayEntryPtr<TPtr extends Pointer>(array: System_Array<TPtr>, index: number, itemSize: number): TPtr;

View File

@ -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<any>, 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<any>, 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<any>;
// 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<any> | 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[][];
}

View File

@ -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<object>(
options.RequestInit = new RequestInit
{
Credentials = GetDefaultCredentialsString(),
Headers = GetHeadersAsStringArray(request),
Method = request.Method.Method
};
options.RequestUri = request.RequestUri.ToString();
RegisteredFunction.InvokeUnmarshalled<int, byte[], string, object>(
$"{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<KeyValuePair<string, IEnumerable<string>>>())
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<string> 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<KeyValuePair<string, IEnumerable<string>>>())
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<HttpResponseMessage> tcs;
@ -98,16 +99,18 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Http
else
{
var responseDescriptor = JsonUtil.Deserialize<ResponseDescriptor>(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;

View File

@ -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<AspNetSiteServerFixture>
{
readonly ServerFixture _apiServerFixture;
readonly IWebElement _appElement;
IWebElement _responseStatus;
IWebElement _responseStatusText;
IWebElement _testOutcome;
public BinaryHttpClientTest(
BrowserFixture browserFixture,
DevHostServerFixture<BasicTestApp.Program> devHostServerFixture,
AspNetSiteServerFixture apiServerFixture,
ITestOutputHelper output)
: base(browserFixture, devHostServerFixture, output)
{
apiServerFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost;
_apiServerFixture = apiServerFixture;
Navigate(ServerPathBase, noReload: true);
_appElement = MountTestComponent<BinaryHttpRequestsComponent>();
}
[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);
}
}
}

View File

@ -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);
}

View File

@ -0,0 +1,71 @@
@using System.Net
@using System.Net.Http
@inject HttpClient Http
<h1>Binary HTTP request tester</h1>
<p>
<div>URI:</div>
<input id="request-uri" bind="@uri" size="60"/>
</p>
<button id="send-request" onclick="@DoRequest">Request</button>
@if (responseStatusCode.HasValue)
{
<h2>Response</h2>
<p><div>Status:</div><span id="response-status">@responseStatusCode</span></p>
<p><div>StatusText:</div><span id="response-status-text">@responseStatusText</span></p>
}
<span id="test-outcome">@testOutcome</span>
@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;
}
}
}

View File

@ -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);

View File

@ -26,6 +26,7 @@
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
<option value="BasicTestApp.HierarchicalImportsTest.Subdir.ComponentUsingImports">Imports statement</option>
<option value="BasicTestApp.HttpClientTest.HttpRequestsComponent">HttpClient tester</option>
<option value="BasicTestApp.HttpClientTest.BinaryHttpRequestsComponent">Binary HttpClient tester</option>
<option value="BasicTestApp.HttpClientTest.CookieCounterComponent">HttpClient cookies</option>
<option value="BasicTestApp.BindCasesComponent">@bind cases</option>
<option value="BasicTestApp.ExternalContentPackage">External content package</option>

View File

@ -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<IActionResult> 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");
}
}
}