From 7effd87d7a274569e8710a860da0d7de35b370b8 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Thu, 21 May 2026 07:04:06 +0200 Subject: [PATCH 1/3] Add AddHttpResponseFormatter and AddSocketSessionInterceptor --- ...uilderExtensions.HttpRequestInterceptor.cs | 216 ++++++++++++++++++ .../GatewayBuilderInterceptorTests.cs | 192 ++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs index b70beec7249..ccb429bd6f2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs @@ -1,5 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using HotChocolate.AspNetCore; +using HotChocolate.AspNetCore.Formatters; +using HotChocolate.AspNetCore.Subscriptions.Protocols; +using HotChocolate.Execution; using HotChocolate.Fusion.Configuration; +using HotChocolate.Transport.Formatters; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.DependencyInjection; @@ -66,4 +72,214 @@ public static IFusionGatewayBuilder AddHttpRequestInterceptor( s.AddSingleton(factory); }); } + + /// + /// Adds an interceptor for GraphQL socket sessions. + /// + /// + /// The . + /// + /// + /// The implementation. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The will be activated with the of + /// the schema services. If your needs to access application services + /// you need to make the services available in the schema services via + /// . + /// + public static IFusionGatewayBuilder AddSocketSessionInterceptor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IFusionGatewayBuilder builder) + where T : class, ISocketSessionInterceptor + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + } + + /// + /// Adds an interceptor for GraphQL socket sessions. + /// + /// + /// The . + /// + /// + /// A factory that creates the interceptor instance. + /// + /// + /// The implementation. + /// + /// + /// Returns the so that configuration can be chained. + /// + /// + /// The passed to the + /// is for the schema services. If you need to access application services + /// you need to either make the services available in the schema services + /// via or use + /// + /// to access the application services from within the schema service provider. + /// + public static IFusionGatewayBuilder AddSocketSessionInterceptor( + this IFusionGatewayBuilder builder, + Func factory) + where T : class, ISocketSessionInterceptor + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(factory); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton(factory); + }); + } + + /// + /// Adds the with specific formatter options. + /// + /// + /// The . + /// + /// + /// Defines whether the underlying + /// should pretty print the JSON which includes: + /// indenting nested JSON tokens, adding new lines, and adding + /// white space between property names and values. + /// By default, the JSON is written without extra white spaces. + /// + /// + /// The default incremental delivery format to use when the client does not specify one + /// via the Accept header. Defaults to . + /// + /// + /// Returns the so that configuration can be chained. + /// + public static IFusionGatewayBuilder AddHttpResponseFormatter( + this IFusionGatewayBuilder builder, + bool indented = false, + IncrementalDeliveryFormat incrementalDeliveryFormat = IncrementalDeliveryFormat.Version_0_2) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton( + sp => DefaultHttpResponseFormatter.Create( + new HttpResponseFormatterOptions + { + Json = new JsonResultFormatterOptions + { + Indented = indented + } + }, + sp.GetRequiredService(), + incrementalDeliveryFormat)); + }); + } + + /// + /// Adds the with specific formatter options. + /// + /// + /// The . + /// + /// + /// The HTTP response formatter options. + /// + /// + /// The default incremental delivery format to use when the client does not specify one + /// via the Accept header. Defaults to . + /// + /// + /// Returns the so that configuration can be chained. + /// + public static IFusionGatewayBuilder AddHttpResponseFormatter( + this IFusionGatewayBuilder builder, + HttpResponseFormatterOptions options, + IncrementalDeliveryFormat incrementalDeliveryFormat = IncrementalDeliveryFormat.Version_0_2) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton( + sp => DefaultHttpResponseFormatter.Create( + options, + sp.GetRequiredService(), + incrementalDeliveryFormat)); + }); + } + + /// + /// Adds a custom HTTP response formatter. + /// + /// + /// The . + /// + /// + /// The type of the custom . + /// + /// + /// Returns the so that configuration can be chained. + /// + public static IFusionGatewayBuilder AddHttpResponseFormatter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IFusionGatewayBuilder builder) + where T : class, IHttpResponseFormatter + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + } + + /// + /// Adds a custom HTTP response formatter. + /// + /// + /// The . + /// + /// + /// The service factory. + /// + /// + /// The type of the custom . + /// + /// + /// Returns the so that configuration can be chained. + /// + public static IFusionGatewayBuilder AddHttpResponseFormatter( + this IFusionGatewayBuilder builder, + Func factory) + where T : class, IHttpResponseFormatter + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(factory); + + return builder.ConfigureSchemaServices( + (_, s) => + { + s.RemoveAll(); + s.AddSingleton(factory); + }); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs new file mode 100644 index 00000000000..2624474aa4c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs @@ -0,0 +1,192 @@ +using HotChocolate.AspNetCore; +using HotChocolate.AspNetCore.Formatters; +using HotChocolate.AspNetCore.Subscriptions.Protocols; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion; + +public class GatewayBuilderInterceptorTests : FusionTestBase +{ + private const string SimpleSchema = + """ + type Query { + field: String + } + """; + + [Fact] + public async Task AddHttpRequestInterceptor_Generic_Should_Register_Custom_Interceptor() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpRequestInterceptor()); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var interceptor = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(interceptor); + } + + [Fact] + public async Task AddHttpRequestInterceptor_Factory_Should_Register_Custom_Interceptor() + { + // arrange + var factoryInvoked = false; + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpRequestInterceptor( + _ => + { + factoryInvoked = true; + return new CustomHttpRequestInterceptor(); + })); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var interceptor = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(interceptor); + Assert.True(factoryInvoked); + } + + [Fact] + public async Task AddSocketSessionInterceptor_Generic_Should_Register_Custom_Interceptor() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddSocketSessionInterceptor()); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var interceptor = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(interceptor); + } + + [Fact] + public async Task AddSocketSessionInterceptor_Factory_Should_Register_Custom_Interceptor() + { + // arrange + var factoryInvoked = false; + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddSocketSessionInterceptor( + _ => + { + factoryInvoked = true; + return new CustomSocketSessionInterceptor(); + })); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var interceptor = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(interceptor); + Assert.True(factoryInvoked); + } + + [Fact] + public async Task AddHttpResponseFormatter_Generic_Should_Register_Custom_Formatter() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpResponseFormatter()); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var formatter = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(formatter); + } + + [Fact] + public async Task AddHttpResponseFormatter_Factory_Should_Register_Custom_Formatter() + { + // arrange + var factoryInvoked = false; + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpResponseFormatter( + _ => + { + factoryInvoked = true; + return new CustomHttpResponseFormatter(); + })); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var formatter = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsType(formatter); + Assert.True(factoryInvoked); + } + + [Fact] + public async Task AddHttpResponseFormatter_Indented_Should_Replace_Default_Formatter() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpResponseFormatter(indented: true)); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var formatter = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsAssignableFrom(formatter); + } + + [Fact] + public async Task AddHttpResponseFormatter_Options_Should_Replace_Default_Formatter() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + var options = new HttpResponseFormatterOptions + { + HttpTransportVersion = HttpTransportVersion.Latest + }; + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.AddHttpResponseFormatter(options)); + + // act + var executor = await gateway.Services.GetRequestExecutorAsync(); + var formatter = executor.Schema.Services.GetRequiredService(); + + // assert + Assert.IsAssignableFrom(formatter); + } + + private sealed class CustomHttpRequestInterceptor : DefaultHttpRequestInterceptor; + + private sealed class CustomSocketSessionInterceptor : DefaultSocketSessionInterceptor; + + private sealed class CustomHttpResponseFormatter : DefaultHttpResponseFormatter; +} From b81736ddd4698b2493bbb44df60b438d7742f59f Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Thu, 21 May 2026 07:48:42 +0200 Subject: [PATCH 2/3] Update tests --- .../Fusion.AspNetCore.Tests/FusionTestBase.cs | 2 + .../GatewayBuilderInterceptorTests.cs | 260 +++++++++++++----- ...otChocolate.Fusion.AspNetCore.Tests.csproj | 1 + 3 files changed, 193 insertions(+), 70 deletions(-) diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs index 5ff6821164b..50f4e821060 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs @@ -278,6 +278,8 @@ protected class Gateway( { public HttpClient CreateClient() => testServer.CreateClient(); + public WebSocketClient CreateWebSocketClient() => testServer.CreateWebSocketClient(); + public IServiceProvider Services => testServer.Services; public List SourceSchemas => sourceSchemas; diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs index 2624474aa4c..76da3e356b3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs @@ -1,8 +1,18 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; using HotChocolate.AspNetCore; using HotChocolate.AspNetCore.Formatters; -using HotChocolate.AspNetCore.Subscriptions.Protocols; +using HotChocolate.AspNetCore.Subscriptions; +using HotChocolate.Collections.Immutable; using HotChocolate.Execution; +using HotChocolate.Transport.Http; +using HotChocolate.Transport.Sockets; +using HotChocolate.Transport.Sockets.Client; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using OperationResult = HotChocolate.Execution.OperationResult; +using TransportOperationRequest = HotChocolate.Transport.OperationRequest; namespace HotChocolate.Fusion; @@ -15,26 +25,39 @@ type Query { } """; + private const string ExtensionKey = "captured"; + [Fact] - public async Task AddHttpRequestInterceptor_Generic_Should_Register_Custom_Interceptor() + public async Task AddHttpRequestInterceptor_Generic_Should_Be_Invoked() { // arrange using var server = CreateSourceSchema("A", SimpleSchema); using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddHttpRequestInterceptor()); + configureGatewayBuilder: b => b + .AddHttpRequestInterceptor() + .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions")); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var interceptor = executor.Schema.Services.GetRequiredService(); + using var result = await client.PostAsync("{ field }", new Uri("http://localhost:5000/graphql")); // assert - Assert.IsType(interceptor); + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "extensions": { + "captured": "from-http-interceptor" + } + } + """); } [Fact] - public async Task AddHttpRequestInterceptor_Factory_Should_Register_Custom_Interceptor() + public async Task AddHttpRequestInterceptor_Factory_Should_Be_Invoked() { // arrange var factoryInvoked = false; @@ -42,42 +65,54 @@ public async Task AddHttpRequestInterceptor_Factory_Should_Register_Custom_Inter using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddHttpRequestInterceptor( - _ => - { - factoryInvoked = true; - return new CustomHttpRequestInterceptor(); - })); + configureGatewayBuilder: b => b + .AddHttpRequestInterceptor( + _ => + { + factoryInvoked = true; + return new ExtensionsHttpRequestInterceptor(); + }) + .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions")); + + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var interceptor = executor.Schema.Services.GetRequiredService(); + using var result = await client.PostAsync("{ field }", new Uri("http://localhost:5000/graphql")); // assert - Assert.IsType(interceptor); + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "extensions": { + "captured": "from-http-interceptor" + } + } + """); Assert.True(factoryInvoked); } [Fact] - public async Task AddSocketSessionInterceptor_Generic_Should_Register_Custom_Interceptor() + public async Task AddSocketSessionInterceptor_Generic_Should_Be_Invoked() { // arrange using var server = CreateSourceSchema("A", SimpleSchema); using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddSocketSessionInterceptor()); + configureGatewayBuilder: b => b + .AddSocketSessionInterceptor() + .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions")); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var interceptor = executor.Schema.Services.GetRequiredService(); + var extensions = await SubscribeOverWebSocketAsync(gateway); // assert - Assert.IsType(interceptor); + Assert.Equal("from-socket-interceptor", extensions.GetProperty(ExtensionKey).GetString()); } [Fact] - public async Task AddSocketSessionInterceptor_Factory_Should_Register_Custom_Interceptor() + public async Task AddSocketSessionInterceptor_Factory_Should_Be_Invoked() { // arrange var factoryInvoked = false; @@ -85,108 +120,193 @@ public async Task AddSocketSessionInterceptor_Factory_Should_Register_Custom_Int using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddSocketSessionInterceptor( - _ => - { - factoryInvoked = true; - return new CustomSocketSessionInterceptor(); - })); + configureGatewayBuilder: b => b + .AddSocketSessionInterceptor( + _ => + { + factoryInvoked = true; + return new ExtensionsSocketSessionInterceptor(); + }) + .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions")); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var interceptor = executor.Schema.Services.GetRequiredService(); + var extensions = await SubscribeOverWebSocketAsync(gateway); // assert - Assert.IsType(interceptor); + Assert.Equal("from-socket-interceptor", extensions.GetProperty(ExtensionKey).GetString()); Assert.True(factoryInvoked); } [Fact] - public async Task AddHttpResponseFormatter_Generic_Should_Register_Custom_Formatter() + public async Task AddHttpResponseFormatter_Indented_Should_Indent_Response_Body() { // arrange using var server = CreateSourceSchema("A", SimpleSchema); using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddHttpResponseFormatter()); + configureGatewayBuilder: b => b + .AddHttpRequestInterceptor() + .AddHttpResponseFormatter(indented: true)); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var formatter = executor.Schema.Services.GetRequiredService(); + var body = await PostQueryAndReadBodyAsync(gateway, "{ field }"); // assert - Assert.IsType(formatter); + Assert.Equal( + """ + { + "data": { + "field": "Query" + } + } + """, + body); } [Fact] - public async Task AddHttpResponseFormatter_Factory_Should_Register_Custom_Formatter() + public async Task AddHttpResponseFormatter_Generic_Should_Use_Custom_Formatter() { // arrange - var factoryInvoked = false; using var server = CreateSourceSchema("A", SimpleSchema); using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddHttpResponseFormatter( - _ => - { - factoryInvoked = true; - return new CustomHttpResponseFormatter(); - })); + configureGatewayBuilder: b => b.AddHttpResponseFormatter()); + + var client = gateway.CreateClient(); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var formatter = executor.Schema.Services.GetRequiredService(); + var statusCode = await PostQueryAndReadStatusAsync(client, "{ field }"); // assert - Assert.IsType(formatter); - Assert.True(factoryInvoked); + Assert.Equal((HttpStatusCode)418, statusCode); } [Fact] - public async Task AddHttpResponseFormatter_Indented_Should_Replace_Default_Formatter() + public async Task AddHttpResponseFormatter_Factory_Should_Use_Custom_Formatter() { // arrange + var factoryInvoked = false; using var server = CreateSourceSchema("A", SimpleSchema); using var gateway = await CreateCompositeSchemaAsync( [("A", server)], - configureGatewayBuilder: b => b.AddHttpResponseFormatter(indented: true)); + configureGatewayBuilder: b => b.AddHttpResponseFormatter( + _ => + { + factoryInvoked = true; + return new TeapotHttpResponseFormatter(); + })); + + var client = gateway.CreateClient(); // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var formatter = executor.Schema.Services.GetRequiredService(); + var statusCode = await PostQueryAndReadStatusAsync(client, "{ field }"); // assert - Assert.IsAssignableFrom(formatter); + Assert.Equal((HttpStatusCode)418, statusCode); + Assert.True(factoryInvoked); } - [Fact] - public async Task AddHttpResponseFormatter_Options_Should_Replace_Default_Formatter() + private static RequestMiddleware EchoExtensionsMiddleware => + (_, _) => context => + { + if (context.Request.Extensions is { } extensions + && extensions.Document.RootElement.TryGetProperty(ExtensionKey, out var value) + && value.GetString() is { } stringValue) + { + context.Result = new OperationResult( + ImmutableOrderedDictionary.Empty.Add(ExtensionKey, stringValue)); + } + + return default; + }; + + private static async Task PostQueryAndReadBodyAsync(Gateway gateway, string query) { - // arrange - using var server = CreateSourceSchema("A", SimpleSchema); - var options = new HttpResponseFormatterOptions + using var http = gateway.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/graphql") { - HttpTransportVersion = HttpTransportVersion.Latest + Content = JsonContent.Create(new { query }) }; + request.Headers.Add("Accept", "application/graphql-response+json"); + using var response = await http.SendAsync(request); + return await response.Content.ReadAsStringAsync(); + } - using var gateway = await CreateCompositeSchemaAsync( - [("A", server)], - configureGatewayBuilder: b => b.AddHttpResponseFormatter(options)); + private static async Task PostQueryAndReadStatusAsync(HttpClient client, string query) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/graphql") + { + Content = JsonContent.Create(new { query }) + }; + request.Headers.Add("Accept", "application/graphql-response+json"); + using var response = await client.SendAsync(request); + return response.StatusCode; + } - // act - var executor = await gateway.Services.GetRequestExecutorAsync(); - var formatter = executor.Schema.Services.GetRequiredService(); + private static async Task SubscribeOverWebSocketAsync(Gateway gateway) + { + var webSocketClient = gateway.CreateWebSocketClient(); + webSocketClient.ConfigureRequest = r => r.Headers.SecWebSocketProtocol = WellKnownProtocols.GraphQL_Transport_WS; - // assert - Assert.IsAssignableFrom(formatter); + using var webSocket = await webSocketClient.ConnectAsync( + new Uri("ws://localhost:5000/graphql"), + CancellationToken.None); + + await using var client = await SocketClient.ConnectAsync(webSocket); + using var result = await client.ExecuteAsync(new TransportOperationRequest("{ field }")); + + await foreach (var operationResult in result.ReadResultsAsync()) + { + using (operationResult) + { + return operationResult.Extensions.Clone(); + } + } + + throw new InvalidOperationException("No result received over the WebSocket."); } - private sealed class CustomHttpRequestInterceptor : DefaultHttpRequestInterceptor; + private sealed class ExtensionsHttpRequestInterceptor : DefaultHttpRequestInterceptor + { + public override async ValueTask OnCreateAsync( + HttpContext context, + IRequestExecutor requestExecutor, + OperationRequestBuilder requestBuilder, + CancellationToken cancellationToken) + { + await base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken); - private sealed class CustomSocketSessionInterceptor : DefaultSocketSessionInterceptor; + requestBuilder.SetExtensions( + JsonSerializer.SerializeToDocument( + new Dictionary { [ExtensionKey] = "from-http-interceptor" })); + } + } - private sealed class CustomHttpResponseFormatter : DefaultHttpResponseFormatter; + private sealed class ExtensionsSocketSessionInterceptor : DefaultSocketSessionInterceptor + { + public override ValueTask OnRequestAsync( + ISocketSession session, + string operationSessionId, + OperationRequestBuilder requestBuilder, + CancellationToken cancellationToken = default) + { + requestBuilder.SetExtensions( + JsonSerializer.SerializeToDocument( + new Dictionary { [ExtensionKey] = "from-socket-interceptor" })); + + return default; + } + } + + private sealed class TeapotHttpResponseFormatter : DefaultHttpResponseFormatter + { + protected override HttpStatusCode OnDetermineStatusCode( + OperationResult result, + FormatInfo format, + HttpStatusCode? proposedStatusCode) + => (HttpStatusCode)418; + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj index e78a96ef5cc..73dec6154f1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj @@ -11,6 +11,7 @@ + From bf496c654a26462c280d9bcdf30b42b1cc09515a Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Thu, 21 May 2026 08:00:45 +0200 Subject: [PATCH 3/3] Cleanup --- ...NetCoreServiceCollectionExtensions.Http.cs | 13 +++++++++ ...uilderExtensions.HttpRequestInterceptor.cs | 14 +++++++++ .../GatewayBuilderInterceptorTests.cs | 29 ++++++------------- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Http.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Http.cs index 2d4c9ef2115..e56141b7eb2 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Http.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Http.cs @@ -193,6 +193,11 @@ public static IRequestExecutorBuilder AddHttpResponseFormatter( /// /// Returns the so that configuration can be chained. /// + /// + /// The will be activated with the of the schema services. + /// If your needs to access application services you need to + /// make the services available in the schema services via . + /// public static IRequestExecutorBuilder AddHttpResponseFormatter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( this IRequestExecutorBuilder builder) @@ -221,6 +226,14 @@ public static IRequestExecutorBuilder AddHttpResponseFormatter< /// /// Returns the so that configuration can be chained. /// + /// + /// The passed to the + /// is for the schema services. If you need to access application services + /// you need to either make the services available in the schema services + /// via or use + /// + /// to access the application services from within the schema service provider. + /// public static IRequestExecutorBuilder AddHttpResponseFormatter( this IRequestExecutorBuilder builder, Func factory) diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs index ccb429bd6f2..216220dfedf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/AspNetCoreFusionGatewayBuilderExtensions.HttpRequestInterceptor.cs @@ -237,6 +237,12 @@ public static IFusionGatewayBuilder AddHttpResponseFormatter( /// /// Returns the so that configuration can be chained. /// + /// + /// The will be activated with the of + /// the schema services. If your needs to access application services + /// you need to make the services available in the schema services via + /// . + /// public static IFusionGatewayBuilder AddHttpResponseFormatter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( this IFusionGatewayBuilder builder) @@ -267,6 +273,14 @@ public static IFusionGatewayBuilder AddHttpResponseFormatter< /// /// Returns the so that configuration can be chained. /// + /// + /// The passed to the + /// is for the schema services. If you need to access application services + /// you need to either make the services available in the schema services + /// via or use + /// + /// to access the application services from within the schema service provider. + /// public static IFusionGatewayBuilder AddHttpResponseFormatter( this IFusionGatewayBuilder builder, Func factory) diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs index 76da3e356b3..c2edfa359ad 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs @@ -46,14 +46,7 @@ public async Task AddHttpRequestInterceptor_Generic_Should_Be_Invoked() // assert using var response = await result.ReadAsResultAsync(); - response.MatchInlineSnapshot( - """ - { - "extensions": { - "captured": "from-http-interceptor" - } - } - """); + Assert.Equal("from-http-interceptor", response.Extensions.GetProperty(ExtensionKey).GetString()); } [Fact] @@ -81,14 +74,7 @@ public async Task AddHttpRequestInterceptor_Factory_Should_Be_Invoked() // assert using var response = await result.ReadAsResultAsync(); - response.MatchInlineSnapshot( - """ - { - "extensions": { - "captured": "from-http-interceptor" - } - } - """); + Assert.Equal("from-http-interceptor", response.Extensions.GetProperty(ExtensionKey).GetString()); Assert.True(factoryInvoked); } @@ -248,17 +234,20 @@ private static async Task PostQueryAndReadStatusAsync(HttpClient private static async Task SubscribeOverWebSocketAsync(Gateway gateway) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = cts.Token; + var webSocketClient = gateway.CreateWebSocketClient(); webSocketClient.ConfigureRequest = r => r.Headers.SecWebSocketProtocol = WellKnownProtocols.GraphQL_Transport_WS; using var webSocket = await webSocketClient.ConnectAsync( new Uri("ws://localhost:5000/graphql"), - CancellationToken.None); + ct); - await using var client = await SocketClient.ConnectAsync(webSocket); - using var result = await client.ExecuteAsync(new TransportOperationRequest("{ field }")); + await using var client = await SocketClient.ConnectAsync(webSocket, ct); + using var result = await client.ExecuteAsync(new TransportOperationRequest("{ field }"), ct); - await foreach (var operationResult in result.ReadResultsAsync()) + await foreach (var operationResult in result.ReadResultsAsync().WithCancellation(ct)) { using (operationResult) {