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 b70beec7249..216220dfedf 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,228 @@ 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.
+ ///
+ ///
+ /// 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)
+ 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.
+ ///
+ ///
+ /// 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)
+ 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/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
new file mode 100644
index 00000000000..c2edfa359ad
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/GatewayBuilderInterceptorTests.cs
@@ -0,0 +1,301 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using HotChocolate.AspNetCore;
+using HotChocolate.AspNetCore.Formatters;
+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;
+
+public class GatewayBuilderInterceptorTests : FusionTestBase
+{
+ private const string SimpleSchema =
+ """
+ type Query {
+ field: String
+ }
+ """;
+
+ private const string ExtensionKey = "captured";
+
+ [Fact]
+ 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()
+ .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions"));
+
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ // act
+ using var result = await client.PostAsync("{ field }", new Uri("http://localhost:5000/graphql"));
+
+ // assert
+ using var response = await result.ReadAsResultAsync();
+ Assert.Equal("from-http-interceptor", response.Extensions.GetProperty(ExtensionKey).GetString());
+ }
+
+ [Fact]
+ public async Task AddHttpRequestInterceptor_Factory_Should_Be_Invoked()
+ {
+ // 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 ExtensionsHttpRequestInterceptor();
+ })
+ .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions"));
+
+ using var client = GraphQLHttpClient.Create(gateway.CreateClient());
+
+ // act
+ using var result = await client.PostAsync("{ field }", new Uri("http://localhost:5000/graphql"));
+
+ // assert
+ using var response = await result.ReadAsResultAsync();
+ Assert.Equal("from-http-interceptor", response.Extensions.GetProperty(ExtensionKey).GetString());
+ Assert.True(factoryInvoked);
+ }
+
+ [Fact]
+ 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()
+ .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions"));
+
+ // act
+ var extensions = await SubscribeOverWebSocketAsync(gateway);
+
+ // assert
+ Assert.Equal("from-socket-interceptor", extensions.GetProperty(ExtensionKey).GetString());
+ }
+
+ [Fact]
+ public async Task AddSocketSessionInterceptor_Factory_Should_Be_Invoked()
+ {
+ // 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 ExtensionsSocketSessionInterceptor();
+ })
+ .UseRequest(EchoExtensionsMiddleware, key: "EchoExtensions"));
+
+ // act
+ var extensions = await SubscribeOverWebSocketAsync(gateway);
+
+ // assert
+ Assert.Equal("from-socket-interceptor", extensions.GetProperty(ExtensionKey).GetString());
+ Assert.True(factoryInvoked);
+ }
+
+ [Fact]
+ 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
+ .AddHttpRequestInterceptor()
+ .AddHttpResponseFormatter(indented: true));
+
+ // act
+ var body = await PostQueryAndReadBodyAsync(gateway, "{ field }");
+
+ // assert
+ Assert.Equal(
+ """
+ {
+ "data": {
+ "field": "Query"
+ }
+ }
+ """,
+ body);
+ }
+
+ [Fact]
+ public async Task AddHttpResponseFormatter_Generic_Should_Use_Custom_Formatter()
+ {
+ // arrange
+ using var server = CreateSourceSchema("A", SimpleSchema);
+
+ using var gateway = await CreateCompositeSchemaAsync(
+ [("A", server)],
+ configureGatewayBuilder: b => b.AddHttpResponseFormatter());
+
+ var client = gateway.CreateClient();
+
+ // act
+ var statusCode = await PostQueryAndReadStatusAsync(client, "{ field }");
+
+ // assert
+ Assert.Equal((HttpStatusCode)418, statusCode);
+ }
+
+ [Fact]
+ 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(
+ _ =>
+ {
+ factoryInvoked = true;
+ return new TeapotHttpResponseFormatter();
+ }));
+
+ var client = gateway.CreateClient();
+
+ // act
+ var statusCode = await PostQueryAndReadStatusAsync(client, "{ field }");
+
+ // assert
+ Assert.Equal((HttpStatusCode)418, statusCode);
+ Assert.True(factoryInvoked);
+ }
+
+ 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)
+ {
+ using var http = gateway.CreateClient();
+ 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 http.SendAsync(request);
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ 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;
+ }
+
+ 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"),
+ ct);
+
+ 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().WithCancellation(ct))
+ {
+ using (operationResult)
+ {
+ return operationResult.Extensions.Clone();
+ }
+ }
+
+ throw new InvalidOperationException("No result received over the WebSocket.");
+ }
+
+ 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);
+
+ requestBuilder.SetExtensions(
+ JsonSerializer.SerializeToDocument(
+ new Dictionary { [ExtensionKey] = "from-http-interceptor" }));
+ }
+ }
+
+ 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 @@
+