Skip to content

Commit 4430c20

Browse files
rose-ajoao-avelino
andauthored
Support graphql-transport-ws websocket protocol (#539)
* Preparing code to add graphql-transport-ws protocol without breaking previous dependant code * Added graphql-transport-ws subprotocol. Tested with Graphql Yoga server * Renamed protocols (dropping the deprecated keyword). Split GraphQLHttpWebSocket into two classes (one per protocol) that inherit from BaseGraphQLHttpWebSocket * fix formatting, do some refactoring * create unit tests for graphql-transport-ws protocol * catch json exceptions on empty close messages * fix handling of regular requests and errors * change IGraphQLWebsocketSerializer to support diffenent payload types +semver: breaking * properly hook up ping pong * implement sub protocol auto-negotiation * implement and test ping/ping * fix formatting error --------- Co-authored-by: joao-avelino <[email protected]>
1 parent 19cc8f9 commit 4430c20

24 files changed

+1031
-301
lines changed

src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,67 @@ public static class GraphQLWebSocketMessageType
7272
public const string GQL_ERROR = "error"; // Server -> Client
7373

7474
/// <summary>
75-
/// Server sends this message to indicate that a GraphQL operation is done, and no more data will arrive for the
76-
/// specific operation.
75+
/// Server -> Client: Server sends this message to indicate that a GraphQL operation is done, and no more data will arrive
76+
/// for the specific operation.
77+
/// Client -> Server: "indicates that the client has stopped listening and wants to complete the subscription. No further
78+
/// events, relevant to the original subscription, should be sent through. Even if the client sent a Complete message for
79+
/// a single-result-operation before it resolved, the result should not be sent through once it does."
80+
/// Replaces the GQL_STOP in graphql-transport-ws
7781
/// id: string : operation ID of the operation that completed
7882
/// </summary>
79-
public const string GQL_COMPLETE = "complete"; // Server -> Client
83+
public const string GQL_COMPLETE = "complete"; // Server -> Client and Client -> Server
8084

8185
/// <summary>
8286
/// Client sends this message in order to stop a running GraphQL operation execution (for example: unsubscribe)
8387
/// id: string : operation id
8488
/// </summary>
8589
public const string GQL_STOP = "stop"; // Client -> Server
90+
91+
92+
// Additional types for graphql-transport-ws, as described in https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
93+
94+
/// <summary>
95+
/// Bidirectional. "Useful for detecting failed connections, displaying latency metrics or other types of network probing.
96+
/// A Pong must be sent in response from the receiving party as soon as possible. The Ping message can be sent at any time
97+
/// within the established socket. The optional payload field can be used to transfer additional details about the ping."
98+
/// payload: Object: ping details
99+
/// </summary>
100+
public const string GQL_PING = "ping"; // Bidirectional
101+
102+
/// <summary>
103+
/// Bidirectional. "The response to the Ping message. Must be sent as soon as the Ping message is received.
104+
/// The Pong message can be sent at any time within the established socket. Furthermore, the Pong message
105+
/// may even be sent unsolicited as an unidirectional heartbeat. The optional payload field can be used to
106+
/// transfer additional details about the pong."
107+
/// payload: Object: pong details
108+
/// </summary>
109+
public const string GQL_PONG = "pong"; // Bidirectional
110+
111+
/// <summary>
112+
/// Client-> Server. "Requests an operation specified in the message payload. This message provides a unique
113+
/// ID field to connect published messages to the operation requested by this message. If there is already an
114+
/// active subscriber for an operation matching the provided ID, regardless of the operation type, the server
115+
/// must close the socket immediately with the event 4409: Subscriber for *unique-operation-id* already exists.
116+
/// The server needs only keep track of IDs for as long as the subscription is active. Once a client completes
117+
/// an operation, it is free to re-use that ID."
118+
/// id: string : operation id
119+
/// payload: Object:
120+
/// operationName : string : subscribe
121+
/// query : string : the subscription query
122+
/// variables : Dictionary(string, string) : a dictionary with variables and their values
123+
/// extensions : Dictionary(string, string) : a dictionary of extensions
124+
/// </summary>
125+
public const string GQL_SUBSCRIBE = "subscribe"; // Client -> Server
126+
127+
128+
/// <summary>
129+
/// Server -> Client. "Operation execution result(s) from the source stream created by the binding Subscribe
130+
/// message. After all results have been emitted, the Complete message will follow indicating stream
131+
/// completion."
132+
/// id: string : operation id
133+
/// payload: Object: ExecutionResult
134+
/// </summary>
135+
136+
public const string GQL_NEXT = "next"; // Server -> Client
137+
86138
}

src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebSocketClient.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ namespace GraphQL.Client.Abstractions.Websocket;
22

33
public interface IGraphQLWebSocketClient : IGraphQLClient
44
{
5+
/// <summary>
6+
/// The negotiated websocket sub-protocol. Will be <see langword="null"/> while no websocket connection is established.
7+
/// </summary>
8+
string? WebSocketSubProtocol { get; }
9+
510
/// <summary>
611
/// Publishes all exceptions which occur inside the websocket receive stream (i.e. for logging purposes)
712
/// </summary>
@@ -16,4 +21,25 @@ public interface IGraphQLWebSocketClient : IGraphQLClient
1621
/// Explicitly opens the websocket connection. Will be closed again on disposing the last subscription.
1722
/// </summary>
1823
Task InitializeWebsocketConnection();
24+
25+
/// <summary>
26+
/// Publishes the payload of all received pong messages (which may be <see langword="null"/>). Subscribing initiates the websocket connection. <br/>
27+
/// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol.
28+
/// </summary>
29+
/// <exception cref="NotSupportedException">the negotiated websocket sub-protocol does not support ping/pong</exception>
30+
IObservable<object?> PongStream { get; }
31+
32+
/// <summary>
33+
/// Sends a ping to the server. <br/>
34+
/// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol.
35+
/// </summary>
36+
/// <exception cref="NotSupportedException">the negotiated websocket sub-protocol does not support ping/pong</exception>
37+
Task SendPingAsync(object? payload);
38+
39+
/// <summary>
40+
/// Sends a pong to the server. This can be used for keep-alive scenarios (the client will automatically respond to pings received from the server). <br/>
41+
/// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol.
42+
/// </summary>
43+
/// <exception cref="NotSupportedException">the negotiated websocket sub-protocol does not support ping/pong</exception>
44+
Task SendPongAsync(object? payload);
1945
}

src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ public interface IGraphQLWebsocketJsonSerializer : IGraphQLJsonSerializer
1010

1111
Task<WebsocketMessageWrapper> DeserializeToWebsocketResponseWrapperAsync(Stream stream);
1212

13-
GraphQLWebSocketResponse<GraphQLResponse<TResponse>> DeserializeToWebsocketResponse<TResponse>(byte[] bytes);
13+
GraphQLWebSocketResponse<TResponse> DeserializeToWebsocketResponse<TResponse>(byte[] bytes);
1414
}

src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ public byte[] SerializeToBytes(GraphQLWebSocketRequest request)
4040

4141
public Task<WebsocketMessageWrapper> DeserializeToWebsocketResponseWrapperAsync(Stream stream) => DeserializeFromUtf8Stream<WebsocketMessageWrapper>(stream);
4242

43-
public GraphQLWebSocketResponse<GraphQLResponse<TResponse>> DeserializeToWebsocketResponse<TResponse>(byte[] bytes) =>
44-
JsonConvert.DeserializeObject<GraphQLWebSocketResponse<GraphQLResponse<TResponse>>>(Encoding.UTF8.GetString(bytes),
43+
public GraphQLWebSocketResponse<TResponse> DeserializeToWebsocketResponse<TResponse>(byte[] bytes) =>
44+
JsonConvert.DeserializeObject<GraphQLWebSocketResponse<TResponse>>(Encoding.UTF8.GetString(bytes),
4545
JsonSerializerSettings);
4646

4747
public Task<GraphQLResponse<TResponse>> DeserializeFromUtf8StreamAsync<TResponse>(Stream stream, CancellationToken cancellationToken) => DeserializeFromUtf8Stream<GraphQLResponse<TResponse>>(stream);

src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ private void ConfigureMandatorySerializerOptions()
4242

4343
public Task<WebsocketMessageWrapper> DeserializeToWebsocketResponseWrapperAsync(Stream stream) => JsonSerializer.DeserializeAsync<WebsocketMessageWrapper>(stream, Options).AsTask();
4444

45-
public GraphQLWebSocketResponse<GraphQLResponse<TResponse>> DeserializeToWebsocketResponse<TResponse>(byte[] bytes) =>
46-
JsonSerializer.Deserialize<GraphQLWebSocketResponse<GraphQLResponse<TResponse>>>(new ReadOnlySpan<byte>(bytes),
45+
public GraphQLWebSocketResponse<TResponse> DeserializeToWebsocketResponse<TResponse>(byte[] bytes) =>
46+
JsonSerializer.Deserialize<GraphQLWebSocketResponse<TResponse>>(new ReadOnlySpan<byte>(bytes),
4747
Options);
4848
}

src/GraphQL.Client/GraphQLHttpClient.cs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,18 @@ public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable
3030
/// </summary>
3131
public GraphQLHttpClientOptions Options { get; }
3232

33-
/// <summary>
34-
/// Publishes all exceptions which occur inside the websocket receive stream (i.e. for logging purposes)
35-
/// </summary>
33+
/// <inheritdoc />
3634
public IObservable<Exception> WebSocketReceiveErrors => GraphQlHttpWebSocket.ReceiveErrors;
3735

38-
/// <summary>
39-
/// the websocket connection state
40-
/// </summary>
36+
/// <inheritdoc />
37+
public string? WebSocketSubProtocol => GraphQlHttpWebSocket.WebsocketProtocol;
38+
39+
/// <inheritdoc />
4140
public IObservable<GraphQLWebsocketConnectionState> WebsocketConnectionState => GraphQlHttpWebSocket.ConnectionState;
4241

42+
/// <inheritdoc />
43+
public IObservable<object?> PongStream => GraphQlHttpWebSocket.GetPongStream();
44+
4345
#region Constructors
4446

4547
public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serializer)
@@ -78,7 +80,7 @@ public GraphQLHttpClient(GraphQLHttpClientOptions options, IGraphQLWebsocketJson
7880
public async Task<GraphQLResponse<TResponse>> SendQueryAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
7981
{
8082
return Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme()
81-
? await GraphQlHttpWebSocket.SendRequest<TResponse>(request, cancellationToken).ConfigureAwait(false)
83+
? await GraphQlHttpWebSocket.SendRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false)
8284
: await SendHttpRequestAsync<TResponse>(request, cancellationToken).ConfigureAwait(false);
8385
}
8486

@@ -103,12 +105,15 @@ public IObservable<GraphQLResponse<TResponse>> CreateSubscriptionStream<TRespons
103105

104106
#endregion
105107

106-
/// <summary>
107-
/// Explicitly opens the websocket connection. Will be closed again on disposing the last subscription.
108-
/// </summary>
109-
/// <returns></returns>
108+
/// <inheritdoc />
110109
public Task InitializeWebsocketConnection() => GraphQlHttpWebSocket.InitializeWebSocket();
111110

111+
/// <inheritdoc />
112+
public Task SendPingAsync(object? payload) => GraphQlHttpWebSocket.SendPingAsync(payload);
113+
114+
/// <inheritdoc />
115+
public Task SendPongAsync(object? payload) => GraphQlHttpWebSocket.SendPongAsync(payload);
116+
112117
#region Private Methods
113118

114119
private async Task<GraphQLHttpResponse<TResponse>> SendHttpRequestAsync<TResponse>(GraphQLRequest request, CancellationToken cancellationToken = default)
@@ -143,9 +148,9 @@ private GraphQLHttpWebSocket CreateGraphQLHttpWebSocket()
143148
throw new InvalidOperationException("no endpoint configured");
144149

145150
var webSocketEndpoint = Options.WebSocketEndPoint ?? Options.EndPoint.GetWebSocketUri();
146-
return webSocketEndpoint.HasWebSocketScheme()
147-
? new GraphQLHttpWebSocket(webSocketEndpoint, this)
148-
: throw new InvalidOperationException($"uri \"{webSocketEndpoint}\" is not a websocket endpoint");
151+
return !webSocketEndpoint.HasWebSocketScheme()
152+
? throw new InvalidOperationException($"uri \"{webSocketEndpoint}\" is not a websocket endpoint")
153+
: new GraphQLHttpWebSocket(webSocketEndpoint, this);
149154
}
150155

151156
#endregion

src/GraphQL.Client/GraphQLHttpClientOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using System.Net.Http.Headers;
33
using System.Net.WebSockets;
4+
using GraphQL.Client.Http.Websocket;
45

56
namespace GraphQL.Client.Http;
67

@@ -19,6 +20,11 @@ public class GraphQLHttpClientOptions
1920
/// </summary>
2021
public Uri? WebSocketEndPoint { get; set; }
2122

23+
/// <summary>
24+
/// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code.
25+
/// </summary>
26+
public string? WebSocketProtocol { get; set; } = WebSocketProtocols.AUTO_NEGOTIATE;
27+
2228
/// <summary>
2329
/// The <see cref="System.Net.Http.HttpMessageHandler"/> that is going to be used
2430
/// </summary>

0 commit comments

Comments
 (0)