Skip to content

GraphQL.Client: add Websocket support for Blazor WebAssembly host #262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
bjorg opened this issue Aug 16, 2020 · 24 comments
Closed

GraphQL.Client: add Websocket support for Blazor WebAssembly host #262

bjorg opened this issue Aug 16, 2020 · 24 comments

Comments

@bjorg
Copy link
Contributor

bjorg commented Aug 16, 2020

I would like to help address this issue as I'm a big fan of Blazor WebAssembly and a strong believer in GraphQL as the best API tech. So, supporting GraphQL.Client is of paramount interest. If there is already an effort underway to achieve this, please close this issue as a duplicate and point me in the right direction.

Problem

I've started looking at the code to better understand where the problem is originating from. At the core of the problem are the limited capabilities of the Blazor WASM runtime. For instance, it's not possible to create threads or even block on a thread with Task.Wait() or Task.Result. All operations must be asynchronous as there is only one main thread in the browser execution environment.

The implementation of the GraphQL.Client.Http.Websocket.GraphQLHttpWebSocket class uses System.Reactive.Concurency.EventLoopScheduler, which creates a thread internally. This causes the System.NotSupportedException: Cannot start threads on this runtime. error when the websocket connection is opened.

Solution

There are 2 approaches to potentially address this problem:

Approach 1 - Blazor WASM-friendly System.Reactive.Concurrency.IScheduler implementation

Implement a Blazor WASM-friendly version of System.Reactive.Concurrency.IScheduler that does not require background threads. It would only work reliably when scheduled operations are asynchronous. However, it would be the least invasive change.

Benefits

  • Least invasive
  • Least amount of new code
  • Requires small initialization change in GraphQLHttpWebSocket that could be controlled via a GraphQLHttpClientOptions property to allow current and new implementations to exist side-by-side

Drawback

  • No compile-time detection if the asynchronous requirement is not honored

Approach 2 - Replace System.Reactive in favor of a pure asynchronous implementation

Benefits

  • Removes dependency on System.Reactive (less is better)
  • Clean asynchronous interface enables compiler to assist with async/await code pattern enforcement.
  • Doesn't require to understand how System.Reactive works by deferring continuations to Task-related methods

Drawbacks

  • Breaking change if public interface of GraphQL.Client exposes IObservable
  • Lots of code changes
  • Even more complexity if old and new implementations need to exists side-by-side

Additional

As follow up to this issue, there should also be a sample Blazor WASM application to showcase and test the proper behavior.

Related

The following issues are related:

@bjorg
Copy link
Contributor Author

bjorg commented Aug 16, 2020

I'll update the issue description based on feedback in this discussion.

@rose-a
Copy link
Collaborator

rose-a commented Aug 17, 2020

Hi @bjorg, thanks for your initiative!

I've never worked with Blazor WebAssembly, so I'd be very happy if someone familiar with this environment contributes to this.

As of now I'd favor Approach 1, since the whole subscription thing is built around System.Reactive and using it this way feels very "natural" IMO.

The reactive approach isn't implemented perfectly yet, as you can see in #260 and #161.

Perhaps GraphQLHttpWebSocket can be refactored to work properly without explicitly specifying an IScheduler, which would likely solve the problem for WASM, too.

@bjorg
Copy link
Contributor Author

bjorg commented Aug 17, 2020

I have never worked with System.Reactive, so this will be a fun opportunity to do so. I agree that the risk/benefit ratio is greatly in advantage of Approach 1. I'll spend a couple of hours this week to see how far I can get with it. I just wanted to make sure it's time well spent and not a duplicative effort.

@ghost
Copy link

ghost commented Sep 4, 2020

@bjorg @rose-a I cannot get the example provided here https://github.com/graphql-dotnet/graphql-client/tree/master/examples/GraphQL.Client.Example to work in a Blazor WebAssembly.

The call is made without an error, but the response contains no data.

Is my problem related to this thread? I would love to use GraphQL in a Blazor WebAssembly.

Thank you,

Karl

@bjorg
Copy link
Contributor Author

bjorg commented Sep 4, 2020

@kdawg1406 This issue pertains to using WebSocket with GraphQL, which is required for subscriptions or when enabling Options.UseWebSocketForQueriesAndMutations. It otherwise works for doing queries against AWS AppSync.

If the response is empty it could be a CORS issue, which blocks the browser from reading the response body. Check the returned HTTP headers.

@rose-a
Copy link
Collaborator

rose-a commented Sep 4, 2020

@bjorg In the latest release all the "EventLoopSchedulers" have been eliminated... Could you please test if there are still threading issues using this lib with WebAssembly?

@bjorg
Copy link
Contributor Author

bjorg commented Sep 4, 2020

@rose-a Oh nice! I will spend some time on it today an report back.

@ghost
Copy link

ghost commented Sep 4, 2020

@bjorg I'm using the sample app from this repo that makes the call to a public GraphQL site. I don't think cors is the issue.

@rose-a I'm guessing that Blazor WASM is not a high priority scenario. I'll try and hand code my Blazor GraphQL calls.

@bjorg
Copy link
Contributor Author

bjorg commented Sep 4, 2020

@kdawg1406 The original issue has been resolved. However, I had to also add the following code to ConnectAsync() because Blazor WASM doesn't support the ClientCertificates and UseDefaultCredentials properties.

try
{
    _clientWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates;
    _clientWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials;
}
catch(PlatformNotSupportedException)
{

    // ignore error as Blazor WebAssembly does not support these properties
}

Even with these fixes, I can't get it to run, because of an issue in ClientWebSocket. I'm using ConfigureWebsocketOptions callback to set a custom authorization header. I can confirm it's being invoked. However, when I look at the network traffic in the browser, the X-Api-Key header is nowhere to be found and the request fails.

_client.Options.ConfigureWebsocketOptions = options => {
    System.Diagnostics.Debug.WriteLine($"setting X-Api-Key header");
    options.SetRequestHeader("X-Api-Key", "--my-very-secret-api-key--");
};

@ghost
Copy link

ghost commented Sep 4, 2020

@bjorg thank you for trying. I'm going to try and use the IHTTPClientFactory in .NET Core and make the calls using this. I'll need to write the code to make the PostAsyc and unpack the results; either data or errors returned.

@bjorg
Copy link
Contributor Author

bjorg commented Sep 6, 2020

Filed an issues about SetRequestHeader(): https://github.com/dotnet/aspnetcore/issues/25657

@bjorg
Copy link
Contributor Author

bjorg commented Sep 8, 2020

Reading on AppSync more, I found out that I misunderstood what they mean by authentication header. Browser websockets don't allow headers to be set on initial connect (see dotnet/runtime#41941 (comment)), instead AppSync expects a header query parameter (see https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html).

@rose-a
Copy link
Collaborator

rose-a commented Sep 8, 2020

hm... yet another solution on how to implement authorization on websockets 🙄

I guess this affords beeing able to set the websocket endpoint separately from the HTTP endpoint....

@bjorg
Copy link
Contributor Author

bjorg commented Sep 8, 2020

One more complication is that the wss:// hostname does not match the https:// hostname in AppSync. To make it a bit easier, it would be nice if the user could specify a wss:// scheme for the endpoint URI. Also, we need to preserve the query parameters, which are currently dropped.

The new implementation for GetWebSocketUri() would look something like this:

private Uri GetWebSocketUri()
{
    string webSocketSchema = Options.EndPoint.Scheme == "https"
        ? "wss"
        : Options.EndPoint.Scheme == "http"
        ? "ws"
        : Options.EndPoint.Scheme;
    return new Uri($"{webSocketSchema}://{Options.EndPoint.Host}:{Options.EndPoint.Port}{Options.EndPoint.AbsolutePath}{Options.EndPoint.Query}");
}

This allows the initialization of the client to look something like this:

var header = new AppSyncHeader {
    Host = "example1234567890000.appsync-api.us-west-2.amazonaws.com",
    ApiKey = "da2-12345678901234567890123456"
};
var uri = $"wss://example1234567890000.appsync-realtime-api.us-west-2.amazonaws.com/graphql"
    + $"?header={Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(header)))}"
    + "&payload=e30=";
_graphQlClient = new GraphQLHttpClient(uri, new SystemTextJsonSerializer());

@rose-a
Copy link
Collaborator

rose-a commented Sep 8, 2020

Sounds reasonable...

@bjorg
Copy link
Contributor Author

bjorg commented Sep 8, 2020

One convenience change in the GraphQLHttpClient constructor would be to check for the wss:// or ws:// scheme and automatically set the Options.UseWebSocketForQueriesAndMutations property.

Something like this:

if ((Options.EndPoint?.Scheme == "wss") || (Options.EndPoint?.Scheme == "ws"))
{
    Options.UseWebSocketForQueriesAndMutations = true;
}

Thoughts?

@bjorg
Copy link
Contributor Author

bjorg commented Sep 8, 2020

Not sure if that is convenience or a must, because what's the point of specifying a websocket endpoint (wss://), but then not using it for all operations?

@bjorg
Copy link
Contributor Author

bjorg commented Sep 8, 2020

Would appreciate some feedback. This is what AppSync expects for subscriptions: https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#subscription-registration-message

I was able to bend it to my will by doing this:

_graphQlClient.CreateSubscriptionStream<GetMyModelTypeResponse>(new GraphQLRequest {
    ["data"] = JsonSerializer.Serialize(new GraphQLRequest {
        Query = @"
            subscription {
                onCreateMyModelType {
                    id
                    title
                }
            }
        "
    }),
    ["extensions"] = new {
        authorization = _header
    }
}).Subscribe(create => _result += "Notification: " + JsonSerializer.Serialize(create) + "\n");

In the above code, _header is a nested object with authorization information. For example, using an API key:

{
    "host":"example1234567890000.appsync-api.us-east-1.amazonaws.com",
    "x-api-key":"da2-12345678901234567890123456"
}

Notice how there is no Query property set on the outermost GraphQLRequest instance and how the data key is a serialized JSON instead of a nested object.

The whole contraption seems to finally work. Except, when the websocket gets a notification with the following payload, my listening code never gets triggered.
SubscriptionNotification

I'm assuming it's not in the right shape, am I right? If so, what shape does the client expect?

@ghost
Copy link

ghost commented Sep 8, 2020

@bjorg @rose-a I blogged about using GraphQL with Blazor WASM. Seems that Blazor WASM uses its own HttpClient. I wrote a super simple service that makes the GraphQL calls using the native Blazor HttpClient and it worked perfectly. Not sure if this will help or not.

Best regards,

Karl

https://oceanware.wordpress.com/2020/09/08/blazor-wasm-graphql-client/

@bjorg
Copy link
Contributor Author

bjorg commented Sep 9, 2020

@kdawg1406 Great, I'll check it out! My quest has been to get subscriptions to work over WebSocket with AppSync. It's been a journey of learning!

On the positive side, I figured out my mistake from the previous comment. I passed in the wrong type on the subscription! Instead of GetMyModelTypeResponse, it should have been OnCreateMyModelType. And then I forgot to call StateHasChanged() as well, which made it look like nothing was coming in! Like I said: a journey of learning! :)

@rose-a
Copy link
Collaborator

rose-a commented Sep 9, 2020

Not sure if that is convenience or a must, because what's the point of specifying a websocket endpoint (wss://), but then not using it for all operations?

I'd say for ws(s):// it's a must, with a http(s):// scheme you'd have a choice. Basically
if ((Options.EndPoint?.Scheme == "wss") || (Options.EndPoint?.Scheme == "ws" || Options.UseWebSocketForQueriesAndMutations)) should cause queries and mutations to be sent via websocket.

@rose-a
Copy link
Collaborator

rose-a commented Sep 9, 2020

I'm assuming it's not in the right shape, am I right? If so, what shape does the client expect?

If I got this spontaneously right I think you need to add a Extensions property to GraphQLRequest. This should work by simply inheriting from GraphQLRequest and passing the extended object to the CreateSubscriptionStream method... make sure the new class is serialized correctly.

The payload, id, type fields belong to GraphQLWebSocketRequest...

@bjorg
Copy link
Contributor Author

bjorg commented Sep 9, 2020

@Rose-e Thanks for the assist! Alas, my mistake was even more basic. Wrong type and forgot to trigger a UI update. Bad combination of mistakes. :)

@bjorg
Copy link
Contributor Author

bjorg commented Sep 15, 2020

This issue is fixed by #274

There are some unrelated issues having to do with using AWS AppSync. I will open a separate ticket for those.

@bjorg bjorg closed this as completed Sep 15, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants