* 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:
parent
e801707706
commit
b5128a7efd
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[][];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue