Skip to content

Commit c73b1ef

Browse files
glen-84michaelstaib
andcommitted
Added WebSocket payload formatter and options for GraphQL over WebSocket (#8135)
Co-authored-by: Michael Staib <michael@chillicream.com>
1 parent d52c105 commit c73b1ef

File tree

8 files changed

+288
-12
lines changed

8 files changed

+288
-12
lines changed

src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Subscriptions.cs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection.Extensions;
22
using HotChocolate.AspNetCore;
3+
using HotChocolate.AspNetCore.Serialization;
34
using HotChocolate.AspNetCore.Subscriptions.Protocols;
45
using HotChocolate.AspNetCore.Subscriptions.Protocols.Apollo;
56
using HotChocolate.AspNetCore.Subscriptions.Protocols.GraphQLOverWebSocket;
@@ -56,8 +57,13 @@ public static IRequestExecutorBuilder AddSocketSessionInterceptor<T>(
5657
private static IRequestExecutorBuilder AddSubscriptionServices(
5758
this IRequestExecutorBuilder builder)
5859
=> builder
59-
.ConfigureSchemaServices(s => s
60-
.TryAddSingleton<ISocketSessionInterceptor, DefaultSocketSessionInterceptor>())
60+
.ConfigureSchemaServices(s =>
61+
{
62+
s.TryAddSingleton<ISocketSessionInterceptor, DefaultSocketSessionInterceptor>();
63+
s.TryAddSingleton<IWebSocketPayloadFormatter>(
64+
_ => new DefaultWebSocketPayloadFormatter(
65+
new WebSocketPayloadFormatterOptions()));
66+
})
6167
.AddApolloProtocol()
6268
.AddGraphQLOverWebSocketProtocol();
6369

@@ -66,12 +72,68 @@ private static IRequestExecutorBuilder AddApolloProtocol(
6672
=> builder.ConfigureSchemaServices(
6773
s => s.AddSingleton<IProtocolHandler>(
6874
sp => new ApolloSubscriptionProtocolHandler(
69-
sp.GetRequiredService<ISocketSessionInterceptor>())));
75+
sp.GetRequiredService<ISocketSessionInterceptor>(),
76+
sp.GetRequiredService<IWebSocketPayloadFormatter>())));
7077

7178
private static IRequestExecutorBuilder AddGraphQLOverWebSocketProtocol(
7279
this IRequestExecutorBuilder builder)
7380
=> builder.ConfigureSchemaServices(
7481
s => s.AddSingleton<IProtocolHandler>(
7582
sp => new GraphQLOverWebSocketProtocolHandler(
76-
sp.GetRequiredService<ISocketSessionInterceptor>())));
83+
sp.GetRequiredService<ISocketSessionInterceptor>(),
84+
sp.GetRequiredService<IWebSocketPayloadFormatter>())));
85+
86+
/// <summary>
87+
/// Adds a custom WebSocket payload formatter to the DI.
88+
/// </summary>
89+
/// <param name="builder">
90+
/// The <see cref="IRequestExecutorBuilder"/>.
91+
/// </param>
92+
/// <typeparam name="T">
93+
/// The type of the custom <see cref="IWebSocketPayloadFormatter"/>.
94+
/// </typeparam>
95+
/// <returns>
96+
/// Returns the <see cref="IRequestExecutorBuilder"/> so that configuration can be chained.
97+
/// </returns>
98+
public static IRequestExecutorBuilder AddWebSocketPayloadFormatter<T>(
99+
this IRequestExecutorBuilder builder)
100+
where T : class, IWebSocketPayloadFormatter
101+
{
102+
builder.ConfigureSchemaServices(services =>
103+
{
104+
services.RemoveAll<IWebSocketPayloadFormatter>();
105+
services.AddSingleton<IWebSocketPayloadFormatter, T>();
106+
});
107+
108+
return builder;
109+
}
110+
111+
/// <summary>
112+
/// Adds a custom WebSocket payload formatter to the DI.
113+
/// </summary>
114+
/// <param name="builder">
115+
/// The <see cref="IRequestExecutorBuilder"/>.
116+
/// </param>
117+
/// <param name="factory">
118+
/// The service factory.
119+
/// </param>
120+
/// <typeparam name="T">
121+
/// The type of the custom <see cref="IWebSocketPayloadFormatter"/>.
122+
/// </typeparam>
123+
/// <returns>
124+
/// Returns the <see cref="IRequestExecutorBuilder"/> so that configuration can be chained.
125+
/// </returns>
126+
public static IRequestExecutorBuilder AddWebSocketPayloadFormatter<T>(
127+
this IRequestExecutorBuilder builder,
128+
Func<IServiceProvider, T> factory)
129+
where T : class, IWebSocketPayloadFormatter
130+
{
131+
builder.ConfigureSchemaServices(services =>
132+
{
133+
services.RemoveAll<IWebSocketPayloadFormatter>();
134+
services.AddSingleton<IWebSocketPayloadFormatter>(factory);
135+
});
136+
137+
return builder;
138+
}
77139
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Text.Json;
2+
using HotChocolate.Execution.Serialization;
3+
4+
namespace HotChocolate.AspNetCore.Serialization;
5+
6+
/// <summary>
7+
/// This represents the default implementation for the <see cref="IWebSocketPayloadFormatter" />.
8+
/// </summary>
9+
public class DefaultWebSocketPayloadFormatter(WebSocketPayloadFormatterOptions options)
10+
: IWebSocketPayloadFormatter
11+
{
12+
private readonly JsonResultFormatter _jsonFormatter = new(options.Json);
13+
14+
/// <inheritdoc />
15+
public void Format(IOperationResult result, Utf8JsonWriter jsonWriter)
16+
{
17+
_jsonFormatter.Format(result, jsonWriter);
18+
}
19+
20+
/// <inheritdoc />
21+
public void Format(IError error, Utf8JsonWriter jsonWriter)
22+
{
23+
_jsonFormatter.FormatError(error, jsonWriter);
24+
}
25+
26+
/// <inheritdoc />
27+
public void Format(IReadOnlyList<IError> errors, Utf8JsonWriter jsonWriter)
28+
{
29+
_jsonFormatter.FormatErrors(errors, jsonWriter);
30+
}
31+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Text.Json;
2+
3+
namespace HotChocolate.AspNetCore.Serialization;
4+
5+
/// <summary>
6+
/// This interface specifies how a GraphQL result is formatted as a WebSocket payload.
7+
/// </summary>
8+
public interface IWebSocketPayloadFormatter
9+
{
10+
/// <summary>
11+
/// Formats the given <paramref name="result"/> into a WebSocket payload.
12+
/// </summary>
13+
/// <param name="result">
14+
/// The GraphQL operation result.
15+
/// </param>
16+
/// <param name="jsonWriter">
17+
/// The JSON writer that is used to write the payload.
18+
/// </param>
19+
void Format(IOperationResult result, Utf8JsonWriter jsonWriter);
20+
21+
/// <summary>
22+
/// Formats the given <paramref name="error"/> into a WebSocket payload.
23+
/// </summary>
24+
/// <param name="error">
25+
/// The GraphQL execution error.
26+
/// </param>
27+
/// <param name="jsonWriter">
28+
/// The JSON writer that is used to write the error.
29+
/// </param>
30+
void Format(IError error, Utf8JsonWriter jsonWriter);
31+
32+
/// <summary>
33+
/// Formats the given <paramref name="errors"/> into a WebSocket payload.
34+
/// </summary>
35+
/// <param name="errors">
36+
/// The GraphQL execution errors.
37+
/// </param>
38+
/// <param name="jsonWriter">
39+
/// The JSON writer that is used to write the errors.
40+
/// </param>
41+
void Format(IReadOnlyList<IError> errors, Utf8JsonWriter jsonWriter);
42+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using HotChocolate.Execution.Serialization;
2+
3+
namespace HotChocolate.AspNetCore.Serialization;
4+
5+
/// <summary>
6+
/// Represents the GraphQL over WebSocket payload formatter options.
7+
/// </summary>
8+
public struct WebSocketPayloadFormatterOptions
9+
{
10+
/// <summary>
11+
/// Gets or sets the JSON result formatter options.
12+
/// </summary>
13+
public JsonResultFormatterOptions Json { get; set; }
14+
}

src/HotChocolate/AspNetCore/src/AspNetCore/Subscriptions/Protocols/Apollo/ApolloSubscriptionProtocolHandler.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using System.Text.Json;
44
using HotChocolate.AspNetCore.Serialization;
55
using HotChocolate.Language;
6-
using HotChocolate.Execution.Serialization;
76
using HotChocolate.Utilities;
87
using static HotChocolate.AspNetCore.Properties.AspNetCoreResources;
98
using static HotChocolate.AspNetCore.Subscriptions.ConnectionContextKeys;
@@ -16,12 +15,15 @@ namespace HotChocolate.AspNetCore.Subscriptions.Protocols.Apollo;
1615

1716
internal sealed class ApolloSubscriptionProtocolHandler : IProtocolHandler
1817
{
19-
private readonly JsonResultFormatter _formatter = new();
2018
private readonly ISocketSessionInterceptor _interceptor;
19+
private readonly IWebSocketPayloadFormatter _formatter;
2120

22-
public ApolloSubscriptionProtocolHandler(ISocketSessionInterceptor interceptor)
21+
public ApolloSubscriptionProtocolHandler(
22+
ISocketSessionInterceptor interceptor,
23+
IWebSocketPayloadFormatter formatter)
2324
{
2425
_interceptor = interceptor;
26+
_formatter = formatter;
2527
}
2628

2729
public string Name => GraphQL_WS;
@@ -263,7 +265,7 @@ public async ValueTask SendErrorMessageAsync(
263265
jsonWriter.WriteString(Id, operationSessionId);
264266
jsonWriter.WriteString(MessageProperties.Type, Utf8Messages.Error);
265267
jsonWriter.WritePropertyName(Payload);
266-
_formatter.FormatError(errors[0], jsonWriter);
268+
_formatter.Format(errors[0], jsonWriter);
267269
jsonWriter.WriteEndObject();
268270
await jsonWriter.FlushAsync(cancellationToken);
269271
await session.Connection.SendAsync(arrayWriter.GetWrittenMemory(), cancellationToken);

src/HotChocolate/AspNetCore/src/AspNetCore/Subscriptions/Protocols/GraphQLOverWebSocket/GraphQLOverWebSocketProtocolHandler.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Diagnostics.CodeAnalysis;
33
using System.Text.Json;
44
using HotChocolate.AspNetCore.Serialization;
5-
using HotChocolate.Execution.Serialization;
65
using HotChocolate.Language;
76
using HotChocolate.Utilities;
87
using static HotChocolate.Transport.Sockets.WellKnownProtocols;
@@ -15,12 +14,15 @@ namespace HotChocolate.AspNetCore.Subscriptions.Protocols.GraphQLOverWebSocket;
1514

1615
internal sealed class GraphQLOverWebSocketProtocolHandler : IGraphQLOverWebSocketProtocolHandler
1716
{
18-
private readonly JsonResultFormatter _formatter = new();
1917
private readonly ISocketSessionInterceptor _interceptor;
18+
private readonly IWebSocketPayloadFormatter _formatter;
2019

21-
public GraphQLOverWebSocketProtocolHandler(ISocketSessionInterceptor interceptor)
20+
public GraphQLOverWebSocketProtocolHandler(
21+
ISocketSessionInterceptor interceptor,
22+
IWebSocketPayloadFormatter formatter)
2223
{
2324
_interceptor = interceptor;
25+
_formatter = formatter;
2426
}
2527

2628
public string Name => GraphQL_Transport_WS;
@@ -239,7 +241,7 @@ public async ValueTask SendErrorMessageAsync(
239241
jsonWriter.WriteString(Id, operationSessionId);
240242
jsonWriter.WriteString(MessageProperties.Type, Utf8Messages.Error);
241243
jsonWriter.WritePropertyName(Payload);
242-
_formatter.FormatErrors(errors, jsonWriter);
244+
_formatter.Format(errors, jsonWriter);
243245
jsonWriter.WriteEndObject();
244246
await jsonWriter.FlushAsync(cancellationToken);
245247
await session.Connection.SendAsync(arrayWriter.GetWrittenMemory(), cancellationToken);

src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Subscriptions/Apollo/WebSocketProtocolTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System.Net.WebSockets;
2+
using HotChocolate.AspNetCore.Serialization;
23
using HotChocolate.AspNetCore.Subscriptions.Protocols;
34
using HotChocolate.AspNetCore.Subscriptions.Protocols.Apollo;
45
using HotChocolate.AspNetCore.Tests.Utilities;
56
using HotChocolate.AspNetCore.Tests.Utilities.Subscriptions.Apollo;
7+
using HotChocolate.Execution.Serialization;
68
using HotChocolate.Language;
79
using Microsoft.AspNetCore.Builder;
810
using Microsoft.Extensions.DependencyInjection;
@@ -518,6 +520,62 @@ public Task Send_Invalid_Message_Not_An_Object()
518520
Assert.Equal(CloseReasons.InvalidMessage, (int)webSocket.CloseStatus!.Value);
519521
});
520522

523+
[Fact]
524+
public Task Send_Start_ReceiveDataOnMutation_StripNull() =>
525+
TryTest(
526+
async ct =>
527+
{
528+
// arrange
529+
using var testServer = CreateStarWarsServer(
530+
configureServices: c =>
531+
c.AddGraphQL()
532+
.AddWebSocketPayloadFormatter(
533+
_ => new DefaultWebSocketPayloadFormatter(
534+
new WebSocketPayloadFormatterOptions
535+
{
536+
Json = new JsonResultFormatterOptions()
537+
{
538+
NullIgnoreCondition = JsonNullIgnoreCondition.All
539+
}
540+
})));
541+
var client = CreateWebSocketClient(testServer);
542+
var webSocket = await ConnectToServerAsync(client, ct);
543+
544+
var document = Utf8GraphQLParser.Parse(
545+
"subscription { onReview(episode: NEW_HOPE) { stars, commentary } }");
546+
var request = new GraphQLRequest(document);
547+
const string subscriptionId = "abc";
548+
549+
// act
550+
await webSocket.SendSubscriptionStartAsync(subscriptionId, request);
551+
552+
// assert
553+
await testServer.SendPostRequestAsync(
554+
new ClientQueryRequest
555+
{
556+
Query =
557+
"""
558+
mutation {
559+
createReview(episode: NEW_HOPE review: {
560+
stars: 5
561+
commentary: null
562+
}) {
563+
stars
564+
commentary
565+
}
566+
}
567+
"""
568+
});
569+
570+
var message = await WaitForMessage(webSocket, "data", ct);
571+
Assert.NotNull(message);
572+
var messagePayload = (Dictionary<string, object?>?)message["payload"];
573+
var messageData = (Dictionary<string, object?>?)messagePayload?["data"];
574+
var messageOnReview = (Dictionary<string, object?>?)messageData?["onReview"];
575+
Assert.NotNull(messageOnReview);
576+
Assert.DoesNotContain("commentary", messageOnReview);
577+
});
578+
521579
private class AuthInterceptor : DefaultSocketSessionInterceptor
522580
{
523581
public override ValueTask<ConnectionStatus> OnConnectAsync(

0 commit comments

Comments
 (0)