Repository navigation
Configuring request options in Browser WebAssembly #34168
Description
Motivation
Before we look at our requirement in .NET, let's look at fetch. .NET WebAssembly implementation for HttpClient uses Browser's "fetch API" as it's transport.
In it's simplest format, all you need to perform a HTTP request using fetch is to specify a url:
const response = await fetch('products/get'); // Perform a GET request to products/get
const products = await response.json(); // Read the response body as jsonAdditional options to configure the request are passed in a secondary options type:
const response = await fetch('products/post', {
method: 'POST',
body: valueToSend,
headers: {
'Content-Type': 'application/json'
}
});fetch does not have a way to options that apply globally or to more than one request, all options must be configured per-request. Application developers may use helper methods for options that need to be configured frequently (for eg https://github.com/moll/js-fetch-defaults#using).
Let's see what this looks like when translated to using HttpRequestMessage:
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "products/post");
requestMessage.Content = valueToSend;
requestMessage.Content.Headers.ContentType = "application/json";In addition to the method, body and headers parameters, fetch has a few other options that are documented here: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Syntax. In the example above, it was fairly easy to construct a fetch request given a HttpRequestMessage since all of these properties were available on the message instance. With HttpClient's programming model, some features are configurable per request, but many are not. In particular, platform-specific settings are always part of the message handler. We'll look at some common cases in webassembly where we really want per-request control of some of these platform-specific settings
Browsers cache results and will return results from the caches. fetch has ways to force the browser to ignore locally cached resources and perform requests. Cache-busting is an important tool in a web developer's arsenal.
// In this sample, we ensure the catalog is always updated from the server.
const response = await fetch('products/catalog.json', { cache: 'no-cache' });If an equivalent option is offered to .NET WebAssembly developers, it is essential that it is available as a per-request setting. Changing cache settings globally is not desirable.
Subresource integrity is a browser security feature that allows a response to only be read if the contents match the specified hash. It ensures that content downloaded to the browser haven't been tampered with in anyway. This is typically valuable if an application downloaded content from a 3rd party hosted site. For e.g.
// The application defers downloading a large payload until it's necessary. It uses integrity to ensure the contents are as expected.
if (shouldShowGrid()) {
const data = await fetch('https://raw.githubusercontent.com/mydata/large.data.json', { integrity: 'precomuted-sha-goes-here' }).then(r => r.json());
showGrid(data);
}Like the cache option, integrity must be a per-request option.
When performing a fetch request, browsers will default to not including the cookie header as part of the request. If your site relies on cookies for authentication, you need these to included. The credentials option allows configuring these:
const response = await fetch('products/get', { credentials: 'include' });In .NET, credentials are configured on the handler. We could conceive of ways to solving this with what's already present in the HttpClient API, such as by using multiple HttpClient instances, or a handler that conditionally configures credentials. However, being able to configure a request setting on a per-request basis is very convenient.
- Streamed versus buffered responses
The response returned from a fetch operation has methods to read it as bytes, strings, json etc. In addition, browsers allow reading the body as a raw (unbuffered) stream. Some applications such as gRPC Web's server streaming feature require streaming responses.
.NET WebAssembly has an implementation of StreamContent over the response body. When they attempted to make it the default, they received user feedback stating that performing sync reads on this content would result in application deadlocks (WASM is single-threaded). There was enough feedback where they feel the need to make returning an unbuffered stream content an option that users have to opt-into.
API Proposal
namespace System.Net.Http
{
public readonly struct HttpRequestOptionsKey<TValue>
{
public HttpRequestOptionsKey(string key)
{
Key = key;
}
public string Key { get; }
}
public sealed class HttpRequestOptions : IDictionary<string, object>
{
// Explicit interface implementation
public bool TryGetValue<TValue>(HttpRequestOptionsKey<TValue> key, out TValue value);
public void Set(HttpRequestOptionsKey<TValue> key, TValue value);
}
public class HttpRequestMessage : IDisposable
{
[Obsolete("Use Options instead.")]
[EditorBrowseable(Never)]
public IDictionary<string, object> Properties => Options;
public HttpRequestOptions Options { get; }
}
}Previous Proposal 2
+ // Inspired by https://github.com/dotnet/runtime/issues/1793
+ interface IHttpRequestOptions
+ {
+ bool TryGet<TValue>(HttpRequestOptionsKey<TValue> key, TValue value);
+ }
+
+
+ class HttpRequestOptions : IHttpRequestOptions
+ {
+ HttpRequestOptions Add(HttpRequestOptionsKey<TValue> key, TValue value);
+ }
// Limiting this list for the sake of brevity.
public partial class HttpClient : System.Net.Http.HttpMessageInvoker
{
public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> DeleteAsync(System.Uri? requestUri, System.Threading.CancellationToken cancellationToken) { throw null; }
+ public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> DeleteAsync(System.Uri? requestUri, System.Net.Http.HttpRequestOptions requestOptions, System.Threading.CancellationToken cancellationToken) { throw null; }
public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> GetAsync(System.Uri? requestUri, System.Net.Http.HttpCompletionOption completionOption, System.Threading.CancellationToken cancellationToken) { throw null; }
+ public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> GetAsync(System.Uri? requestUri, System.Net.Http.HttpRequestOptions requestOptions, System.Net.Http.HttpCompletionOption completionOption, System.Threading.CancellationToken cancellationToken) { throw null; }
public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> SendAsync(System.Net.Http.HttpRequestMessage request, System.Net.Http.HttpCompletionOption completionOption, System.Threading.CancellationToken cancellationToken) { throw null; }
+ public System.Threading.Tasks.Task<System.Net.Http.HttpResponseMessage> SendAsync(System.Net.Http.HttpRequestMessage request, System.Net.Http.HttpCompletionOption completionOption, System.Net.Http.HttpRequestOptions requestOptions, System.Threading.CancellationToken cancellationToken) { throw null; }
}
+ static System.Threading.Tasks.Task<object?> GetFromJsonAsync(this System.Net.Http.HttpClient client, System.Uri? requestUri, System.Type type, System.Net.Http.HttpRequestOptions requestOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; }
partial class HttpRequestMessage : System.IDisposable
{
+ public IHttpRequestOptions RequestOptions { get { throw null; } }
}Usage
- Manually constructing a HttpRequestMessage
var request = new HttpRequestMessage(HttpMethod.Get, "products/catalog.json");
request.Options.Add(BrowserRequestOptions.CacheKey, BrowserRequestCache.NoCache);
// OR convenience extension methods if we deem necessary
request.Options.SetBrowserRequestCache(BrowserRequestCache.NoCache);- HTTP request performed by library or helper code.
var requestOptions = new HttpRequestOptions()
.Add(BrowserRequestOptions.IntegrityKey, "precomuted-sha-goes-here");
var request = await httpClient.GetFromJsonAsync("https://raw.githubusercontent.com/mydata/large.data.json", requestOptions);- HttpRequestMessage constructed by external library
var requestOptions = new HttpRequestOptions();
requestOptions.Add(BrowserRequestOptions.Credentials, BrowserRequestCredentials.Include);
var request = myLibrary.CreateRequest(...);
httpClient.SendAsync(request, requestOptions, cancellationToken);Previous Proposal 1
WebAssembly's HttpClient uses the Fetch API as a transport for HTTP. Similar to how WinHttpHandler allow fine-grained platform specific config, there are options on fetch that a user may want to configure. This API introduces a WebAssemblyHttpHandler that supports this scenario.
Proposed API
namespace System.Net.Http
{
public partial class WebAssemblyHttpHandler : System.Net.Http.HttpMessageHandler
{
public WebAssemblyHttpHandler() { }
// https://developer.mozilla.org/en-US/docs/Web/API/Request/integrity
public string? Integrity { get; set; }
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache
public RequestCache? RequestCache { get; set; }
// https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials
public RequestCredentials? RequestCredentials { get; set; }
// https://developer.mozilla.org/en-US/docs/Web/API/Request/mode
public RequestMode? RequestMode { get; set; }
// Determines if the handler streams the response when supported by the platform.
// See https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts.
public bool StreamingEnabled { get; set; }
}
enum RequestCache
{
Default = 0,
NoStore = 1,
Reload = 2,
NoCache = 3,
ForceCache = 4,
OnlyIfCached = 5,
}
enum RequestCredentials
{
Omit = 0,
SameOrigin = 1,
Include = 2,
}
enum RequestMode
{
SameOrigin = 0,
NoCors = 1,
Cors = 2,
Navigate = 3,
}
}Example
var client = new HttpClient(new WebAssemblyHttpHandler
{
RequestCredentials = RequestCredentials.Include,
RequestMode = RequestMode.Cors,
});
client.SendAsync(...)
Activity
davidsh commentedon Mar 27, 2020
@scalablecory
pranavkm commentedon Mar 27, 2020
The current implementation is located here: https://github.com/mono/mono/blob/master/sdks/wasm/framework/src/WebAssembly.Net.Http/WasmHttpMessageHandler.cs. Work's being done to rename the type and add these options as part of mono/mono#19260.
/cc @marek-safar / @kjpou1 / @lewing / @terrajobst
scalablecory commentedon Mar 30, 2020
Given this type is very platform-specific, does it make sense have it in the System namespace or should it be somewhere else?
It looks like this should be read-only based on the Mozilla docs. It appears to never return
nullthere -- will we be normalizing""intonullfor this API?It looks like this doesn't need to be nullable;
RequestCache.Defaultis fine.Our other handlers have a
ICredentials Credentialsproperty. Would it make any sense to use the same here and have some sentinel values instead of an enum? We have precedent with sentinal implementations inCredentialCache.DefaultCredentialsandCredentialCache.DefaultNetworkCredentials, and this would leave the door open for less browsery credential implementations in the future.Should this be static read-only? We have other APIs that use "Supports" instead of "Enabled"; may want to use that here.
scalablecory commentedon Mar 30, 2020
Also, it looks like all of these APIs are at the request level in
fetch, not the handler level (thoughfetchdoes not have a handler). We should think about how that shapes this here.Maybe a derived
HttpRequestMessagemakes some sense, though I can see that causing hurdles for 3rd party libraries.pranavkm commentedon Mar 30, 2020
Based on an offline discussion, the plan was to mirror the platform-specific WinHttpHandler. I chose this namespaces to keep with the pattern there.
The API the handler is attempting to mirror is
window.fetchwhich allows configuring these values - https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Syntax. A usage for this would look like:I wanted to leave these unspecified rather than specify a default, in the event the spec gets changed to have a new default at a future point.
That's an interesting suggestion. The implementation could set the
credentialsoption if it's one of the sentinel credentials, otherwise set theAuthenticationheader. I'm not 100% certain if there's ever a need the header and the fetch option simultaneously, I'll ask around.It's meant to be an option that influences whether
HttpResponseMessage.Contentis an unbuffered stream versus a buffered byte array. The unbuffered stream is required to enable application models that require streaming (such as gRPC). However, having it on by default or making it an implementation detail is problematic - performing a sync read on the returned stream in .NET deadlocks the application (.NET Wasm is single threaded). The property is meant to allow users to opt-in to using streams. In JavaScript, this is what the usage of the two APIs look like:24 remaining items
terrajobst commentedon Jul 6, 2020
@stephentoub @karelz
The goal was to unblock people, which is why I told @scalablecory to remove api-ready-for-review if he disagrees with the proposal.
terrajobst commentedon Jul 6, 2020
@pranavkm please don't edit the API within a comment that represents approval if they are net-new suggestions. These comments are meant to express what was reviewed/approved at a given point in time. Otherwise it will confuse us in the next meeting :-)
Instead, update the issue description. I just did that and put the previous ones under a
<details>tag.terrajobst commentedon Jul 7, 2020
Video
marek-safar commentedon Jul 30, 2020
Implemented in master