From 128de3ead365b96c717724fe99c0782b60a05c3f Mon Sep 17 00:00:00 2001 From: Glen Date: Fri, 2 Oct 2026 16:32:20 +0200 Subject: [PATCH 1/3] Fix GraphQL HTTP responses missing `Vary: Accept` --- .../EndpointRouteBuilderExtensions.cs | 2 + .../Extensions/HttpRequestExtensions.cs | 10 + .../Extensions/HttpResponseExtensions.cs | 85 ++++++ .../DefaultHttpResponseFormatter.cs | 2 +- .../HttpContentNegotiationMiddleware.cs | 36 +++ .../HttpUnsupportedRequestMiddleware.cs | 17 +- .../AspNetCore.Pipeline/MiddlewareFactory.cs | 10 + .../PersistedOperationMiddleware.cs | 4 + .../Extensions/HttpResponseExtensionsTests.cs | 33 +++ .../GraphQLOverHttpSpecTests.cs | 262 +++++++++++++++++- .../HttpContentNegotiationMiddlewareTests.cs | 95 +++++++ .../HttpGetSchemaMiddlewareTests.cs | 2 + .../HttpPostMiddlewareTests.cs | 4 + .../HttpQueryMiddlewareTests.cs | 7 + .../PersistedOperationMiddlewareTests.cs | 35 +++ ...ection_Request_When_Development_Success.md | 1 + ...ction_Request_When_NOT_Development_Fail.md | 1 + ...spection_Request_With_Rule_Removed_Fail.md | 1 + ...zureFunctionServiceCollectionExtensions.cs | 1 + .../IsolatedProcessEndToEndTests.cs | 51 ++++ .../InProcessEndToEndTests.cs | 47 ++++ .../test/Caching.Tests/HttpCachingTests.cs | 25 ++ ...efault_Max_Age_Should_Apply_And_Cache.snap | 6 + ....Default_Scope_Should_Apply_And_Cache.snap | 6 + ...chingTests.JustScope_Should_Not_Cache.snap | 9 +- ...chingTests.Just_Defaults_Should_Cache.snap | 6 + ...hingTests.MaxAgeAndScope_Should_Cache.snap | 6 + ...Combine_Public_Private_Caches_Private.snap | 6 + ...e_Multiple_Should_Cache_Shortest_Time.snap | 6 + ...hingTests.MaxAge_NonZero_Should_Cache.snap | 6 + ...axAge_Combine_Produces_Resolved_Cache.snap | 6 + ...CachingTests.MaxAge_Zero_Should_Cache.snap | 6 + ....No_Applied_Defaults_Should_Not_Cache.snap | 9 +- ...hingTests.QueryError_Should_Not_Cache.snap | 9 +- ...sts.SharedMaxAgeAndScope_Should_Cache.snap | 6 + ...ary_Multiple_Should_Cache_And_Combine.snap | 13 +- ...ests.SharedMaxAgeAndVary_Should_Cache.snap | 11 +- ...axAge_Combine_Produces_Resolved_Cache.snap | 6 + ...Combine_Public_Private_Caches_Private.snap | 6 + .../VariableBatchingTests.cs | 2 + ...ReturnHttp200_When_AcceptIsLegacyJson.snap | 1 + ...p400_When_AcceptIsGraphQLResponseJson.snap | 1 + ...nPlanHeader_Should_Omit_OperationPlan.snap | 1 + ...anHeader_Should_Include_OperationPlan.snap | 1 + ...nPlanHeader_Should_Omit_OperationPlan.snap | 1 + ...anHeader_Should_Include_OperationPlan.snap | 1 + ...nPlanHeader_Should_Omit_OperationPlan.snap | 1 + ...ionTests.ApolloStyle_MD5Hash_NotFound.snap | 1 + ...tionTests.ApolloStyle_MD5Hash_Success.snap | 1 + ...ionTests.ApolloStyle_Sha1Hash_Success.snap | 1 + ...nTests.ApolloStyle_Sha256Hash_Success.snap | 1 + ...ts.HotChocolateStyle_MD5Hash_NotFound.snap | 1 + ...sts.HotChocolateStyle_MD5Hash_Success.snap | 1 + ...ts.HotChocolateStyle_Sha1Hash_Success.snap | 1 + ...Sha256Hash_Query_Empty_String_Success.snap | 1 + ....HotChocolateStyle_Sha256Hash_Success.snap | 1 + ...Standard_Query_Allowed_When_Persisted.snap | 1 + ...Tests.Standard_Query_By_Default_Works.snap | 1 + ...ationTests.Standard_Query_Not_Allowed.snap | 1 + ...andard_Query_Not_Allowed_Custom_Error.snap | 1 + ...Query_Not_Allowed_Even_When_Persisted.snap | 1 + ...uery_Not_Allowed_Override_Per_Request.snap | 1 + website/content/docs/fusion/cache-control.md | 2 +- .../migrating/migrate-from-16-6-to-16-7.md | 8 + .../docs/hotchocolate/server/cache-control.md | 2 +- .../hotchocolate/server/http-transport.md | 22 ++ 66 files changed, 877 insertions(+), 35 deletions(-) create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpContentNegotiationMiddleware.cs create mode 100644 src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs create mode 100644 src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpContentNegotiationMiddlewareTests.cs diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs index d9384e95d20..72e8114c8db 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs @@ -135,6 +135,7 @@ public static IApplicationBuilder MapGraphQL( applicationBuilder .Use(MiddlewareFactory.CreateCancellationMiddleware()) .Use(MiddlewareFactory.CreateWebSocketSubscriptionMiddleware(executor, serverOptions)) + .Use(MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path)) .Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions)) .Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions)) .Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions)) @@ -216,6 +217,7 @@ public static GraphQLHttpEndpointConventionBuilder MapGraphQLHttp( requestPipeline .Use(MiddlewareFactory.CreateCancellationMiddleware()) + .Use(MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path: null)) .Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions)) .Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions)) .Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions)) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpRequestExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpRequestExtensions.cs index 11f6d51f73c..ce5a82a7a1e 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpRequestExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpRequestExtensions.cs @@ -14,6 +14,16 @@ internal static bool AcceptHeaderContainsHtml(this HttpRequest request) && values.Count > 0 && (values[0]?.Contains(ContentType.Html) ?? false); } + internal static bool IsEndpointPath(this HttpRequest request, PathString path) + { + var isBelowPath = request.Path.StartsWithSegments( + path, + StringComparison.OrdinalIgnoreCase, + out var remaining); + + return isBelowPath && remaining.Value is null or "" or "/"; + } + internal static bool IsGetOrHeadMethod(this HttpRequest request) { return HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs index bd9c014fcc2..408ce9256a8 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using static System.Globalization.CultureInfo; using static System.String; @@ -9,6 +10,52 @@ internal static class HttpResponseExtensions private const string ContentDispositionHeader = "Content-Disposition"; private const string ContentDispositionValue = "attachment; filename=\"{0}\""; + /// + /// Adds each comma-separated field name in to the Vary + /// header unless the header already lists it, compared case-insensitively. + /// + public static void AppendVary(this IHeaderDictionary headers, string fieldNames) + { + var vary = headers.Vary; + var remaining = fieldNames.AsSpan(); + var total = 0; + var listed = 0; + + while (TryReadFieldName(ref remaining, out var fieldName)) + { + total++; + + if (ListsFieldName(vary, fieldName)) + { + listed++; + } + } + + if (listed == total) + { + return; + } + + if (listed == 0) + { + headers.Vary = StringValues.Concat(vary, fieldNames); + return; + } + + var missing = new List(total - listed); + remaining = fieldNames.AsSpan(); + + while (TryReadFieldName(ref remaining, out var fieldName)) + { + if (!ListsFieldName(vary, fieldName)) + { + missing.Add(fieldName.ToString()); + } + } + + headers.Vary = StringValues.Concat(vary, Join(", ", missing)); + } + public static IHeaderDictionary SetContentDisposition( this IHeaderDictionary headers, string fileName) @@ -17,4 +64,42 @@ public static IHeaderDictionary SetContentDisposition( Format(InvariantCulture, ContentDispositionValue, fileName); return headers; } + + private static bool ListsFieldName(StringValues values, ReadOnlySpan fieldName) + { + foreach (var value in values) + { + var remaining = value.AsSpan(); + + while (TryReadFieldName(ref remaining, out var listedFieldName)) + { + if (listedFieldName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + private static bool TryReadFieldName( + ref ReadOnlySpan remaining, + out ReadOnlySpan fieldName) + { + while (!remaining.IsEmpty) + { + var comma = remaining.IndexOf(','); + fieldName = (comma < 0 ? remaining : remaining[..comma]).Trim(); + remaining = comma < 0 ? [] : remaining[(comma + 1)..]; + + if (!fieldName.IsEmpty) + { + return true; + } + } + + fieldName = default; + return false; + } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs index 5ea9968f9ab..b941e9daae8 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs @@ -386,7 +386,7 @@ private async ValueTask FormatInternalAsync( if (result.ContextData.TryGetValue(ExecutionContextData.VaryHeaderValue, out var varyValue) && varyValue is string varyHeaderValue) { - response.Headers.Vary = varyHeaderValue; + response.Headers.AppendVary(varyHeaderValue); } OnWriteResponseHeaders(operationResult, format, response.Headers); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpContentNegotiationMiddleware.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpContentNegotiationMiddleware.cs new file mode 100644 index 00000000000..8c9d11e4b70 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpContentNegotiationMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using RequestDelegate = Microsoft.AspNetCore.Http.RequestDelegate; + +namespace HotChocolate.AspNetCore; + +/// +/// Declares Accept as a selecting header, through Vary, on every request whose +/// response the GraphQL endpoint selects by its Accept header: a GET, HEAD, POST, or QUERY +/// on the endpoint. +/// +/// +/// The next middleware in line. +/// +/// +/// The path of the GraphQL endpoint, or null when every request that reaches this +/// middleware is on the endpoint. +/// +internal sealed class HttpContentNegotiationMiddleware(RequestDelegate next, PathString? path) +{ + public Task InvokeAsync(HttpContext context) + { + var request = context.Request; + + if ((path is not { } endpointPath || request.IsEndpointPath(endpointPath)) + && (HttpMethods.IsGet(request.Method) + || HttpMethods.IsHead(request.Method) + || HttpMethods.IsPost(request.Method) + || HttpMethods.IsQuery(request.Method))) + { + context.Response.Headers.AppendVary(HeaderNames.Accept); + } + + return next(context); + } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpUnsupportedRequestMiddleware.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpUnsupportedRequestMiddleware.cs index ad9f4aba306..b78311d4342 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpUnsupportedRequestMiddleware.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpUnsupportedRequestMiddleware.cs @@ -44,7 +44,7 @@ public HttpUnsupportedRequestMiddleware( public async Task InvokeAsync(HttpContext context) { - if (IsGraphQLEndpoint(context.Request)) + if (_path is not { } path || context.Request.IsEndpointPath(path)) { var options = GetOptions(context); @@ -92,21 +92,6 @@ public async Task InvokeAsync(HttpContext context) context.Response.StatusCode = StatusCodes.Status404NotFound; } - private bool IsGraphQLEndpoint(HttpRequest request) - { - if (_path is not { } path) - { - return true; - } - - var isBelowPath = request.Path.StartsWithSegments( - path, - StringComparison.OrdinalIgnoreCase, - out var remaining); - - return isBelowPath && remaining.Value is null or "" or "/"; - } - // The Allow header for the options this middleware serves, built on first use. private string GetAllow(GraphQLServerOptions options) => _allow ??= BuildAllow(options); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/MiddlewareFactory.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/MiddlewareFactory.cs index 74af6d3606b..0170c0b127b 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/MiddlewareFactory.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/MiddlewareFactory.cs @@ -33,6 +33,16 @@ internal static Func CreateWebSocketSubscripti }; } + internal static Func CreateHttpContentNegotiationMiddleware( + PathString? path) + { + return next => + { + var middleware = new HttpContentNegotiationMiddleware(next, path); + return context => middleware.InvokeAsync(context); + }; + } + internal static Func CreateHttpPostMiddleware( HttpRequestExecutorProxy executor, GraphQLServerOptions serverOptions) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/PersistedOperationMiddleware.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/PersistedOperationMiddleware.cs index f46d15e5586..b0d502d9a0f 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/PersistedOperationMiddleware.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/PersistedOperationMiddleware.cs @@ -26,6 +26,10 @@ internal static void MapPersistedOperationMiddleware( { var executorProxy = HttpRequestExecutorProxy.Create(services, schemaName); var serverOptions = services.GetRequiredService>().Get(schemaName); + var negotiation = MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path: null); + + ((IEndpointConventionBuilder)groupBuilder).Add( + endpoint => endpoint.RequestDelegate = negotiation(endpoint.RequestDelegate!)); groupBuilder.MapGet( "/{operationId}", diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs new file mode 100644 index 00000000000..96856df9b7b --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; + +namespace HotChocolate.AspNetCore.Extensions; + +public sealed class HttpResponseExtensionsTests +{ + [Theory] + [InlineData(new string[0], "Accept", new[] { "Accept" })] + [InlineData(new[] { "Origin" }, "Accept", new[] { "Origin", "Accept" })] + [InlineData(new[] { "accept" }, "Accept", new[] { "accept" })] + [InlineData(new[] { "Accept" }, "x-bar, x-foo", new[] { "Accept", "x-bar, x-foo" })] + [InlineData(new[] { "Accept" }, "accept, x-foo", new[] { "Accept", "x-foo" })] + [InlineData(new[] { "Origin, Accept" }, "ACCEPT", new[] { "Origin, Accept" })] + public void AppendVary_Should_AddOnlyNamesNotYetListed_When_NamesAreGiven( + string[] existing, + string fieldNames, + string[] expected) + { + // arrange + IHeaderDictionary headers = new HeaderDictionary(); + + if (existing.Length > 0) + { + headers.Vary = existing; + } + + // act + headers.AppendVary(fieldNames); + + // assert + Assert.Equal(expected, headers.Vary.ToArray()); + } +} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs index ad04255cbc1..d0a4f856d34 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs @@ -109,6 +109,7 @@ public async Task SingleResult_Success(string? acceptHeader, HttpTransportVersio .Add(response) .MatchInline( @$"Headers: + Vary: Accept Content-Type: {expectedContentType} --------------------------> Status Code: OK @@ -140,6 +141,7 @@ public async Task SingleResult_MultipartAcceptHeader(string acceptHeader) .MatchInline( """ Headers: + Vary: Accept Content-Type: multipart/mixed; boundary="-" --------------------------> Status Code: OK @@ -178,6 +180,7 @@ public async Task SingleResult_Should_WriteOneNextEvent_When_EventStreamIsAccept .MatchInline( """ Headers: + Vary: Accept Content-Type: text/event-stream; charset=utf-8 --------------------------> Status Code: OK @@ -235,6 +238,7 @@ public async Task Query_No_Body(string? acceptHeader, HttpTransportVersion trans .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: {{{expectedContentType}}} --------------------------> Status Code: {{{expectedStatusCode}}} @@ -276,6 +280,7 @@ public async Task ValidationError(string? acceptHeader, HttpTransportVersion tra .Add(response) .MatchInline( @$"Headers: + Vary: Accept Content-Type: {expectedContentType} --------------------------> Status Code: {expectedStatusCode} @@ -342,6 +347,7 @@ public async Task UnsupportedAcceptHeaderValue() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -371,9 +377,12 @@ public async Task UnsupportedApplicationAcceptHeaderValue() .Add(response) .MatchInline( """ + Headers: + Vary: Accept + --------------------------> Status Code: NotAcceptable --------------------------> - + """); } @@ -406,6 +415,7 @@ public async Task EventStream_Sends_KeepAlive() .MatchInline( """ Headers: + Vary: Accept Cache-Control: no-cache Content-Type: text/event-stream; charset=utf-8 --------------------------> @@ -457,6 +467,7 @@ public async Task EventStream_When_Accept_Is_All() .MatchInline( """ Headers: + Vary: Accept Cache-Control: no-cache Content-Type: text/event-stream; charset=utf-8 --------------------------> @@ -508,6 +519,7 @@ public async Task EventStream_When_Accept_Is_All_And_Subscription_Directive() .MatchInline( """ Headers: + Vary: Accept Cache-Control: no-cache Content-Type: text/event-stream; charset=utf-8 --------------------------> @@ -691,6 +703,7 @@ public async Task Post_Should_ReturnPartialSuccess_When_ResultHasDataAndErrors( .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: {{{expectedContentType}}} --------------------------> Status Code: {{{expectedStatusCode}}} @@ -732,6 +745,7 @@ public async Task Post_Should_ReturnPartialSuccess_When_NonNullViolationErasesDa .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1368,6 +1382,7 @@ public async Task Post_Should_ReturnBadRequest_When_BodyHasNoJsonToken( .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -1447,6 +1462,7 @@ public async Task Post_Should_ReturnInternalServerError_When_DocumentIsMissingBe .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError @@ -1500,6 +1516,7 @@ public async Task Post_Should_ReturnInternalServerError_When_CachedDocumentIsMis .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError @@ -1544,6 +1561,7 @@ public async Task Post_Should_ReturnUnprocessableContent_When_DocumentIdIsNotRes .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1577,6 +1595,7 @@ public async Task Post_Should_ReturnInternalServerError_When_DocumentIsMissingBe .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError @@ -1610,6 +1629,7 @@ public async Task Post_Should_ReturnInternalServerError_When_OperationIsMissingB .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError @@ -1646,6 +1666,7 @@ public async Task Post_Should_ReturnInternalServerError_When_VariablesAreMissing .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError @@ -1686,6 +1707,7 @@ public async Task Post_Should_ReturnUnprocessableContent_When_VariableBatchIsEmp .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1745,6 +1767,7 @@ public async Task Get_Should_ReturnUnprocessableContent_When_VariableBatchIsEmpt .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1784,6 +1807,7 @@ public async Task Post_Should_ReturnUnprocessableContent_When_MultipartVariableB .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1886,6 +1910,7 @@ public async Task Post_Should_ReturnBadRequest_When_RequestBatchContainsEmptyVar .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -1920,6 +1945,7 @@ public async Task Post_Should_ExecuteRequest_When_ContentTypeCasingVaries(string .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -2036,6 +2062,233 @@ public async Task Get_Should_ReturnMethodNotAllowed_When_GetRequestsAreDisabled( Assert.Equal(expectedAllow, response.Content.Headers.Allow); } + // A response the endpoint selects by the Accept header names Accept in Vary (RFC 9110, + // section 12.5.5). + [Theory] + [InlineData(Legacy)] + [InlineData(Draft20250508)] + [InlineData(Draft20260903)] + public async Task Get_Should_ReturnVaryAccept_When_QueryIsExecuted( + HttpTransportVersion transportVersion) + { + // arrange + var client = GetClient(transportVersion); + var query = Uri.EscapeDataString("{ __typename }"); + + // act + using var response = await client.GetAsync( + new Uri($"{s_url}?query={query}"), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy)] + [InlineData(Draft20250508)] + [InlineData(Draft20260903)] + public async Task Head_Should_ReturnVaryAccept_When_QueryIsExecuted( + HttpTransportVersion transportVersion) + { + // arrange + var client = GetClient(transportVersion); + var query = Uri.EscapeDataString("{ __typename }"); + + // act + using var request = new HttpRequestMessage( + HttpMethod.Head, + new Uri($"{s_url}?query={query}")); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // assert + Assert.Equal(OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy)] + [InlineData(Draft20250508)] + [InlineData(Draft20260903)] + public async Task Post_Should_ReturnVaryAccept_When_QueryIsExecuted( + HttpTransportVersion transportVersion) + { + // arrange + var client = GetClient(transportVersion); + + // act + using var response = await client.PostAsync( + s_url, + JsonContent.Create(new ClientQueryRequest { Query = "{ __typename }" }), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy)] + [InlineData(Draft20250508)] + [InlineData(Draft20260903)] + public async Task Query_Should_ReturnVaryAccept_When_QueryIsExecuted( + HttpTransportVersion transportVersion) + { + // arrange + var client = GetQueryClient(transportVersion); + + // act + using var request = new HttpRequestMessage(s_queryMethod, s_url); + request.Content = JsonContent.Create(new ClientQueryRequest { Query = "{ __typename }" }); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // assert + Assert.Equal(OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy)] + [InlineData(Draft20250508)] + [InlineData(Draft20260903)] + public async Task Post_Should_ReturnVaryAccept_When_AcceptIsNotSupported( + HttpTransportVersion transportVersion) + { + // arrange + var client = GetClient(transportVersion); + + // act + using var request = new HttpRequestMessage(HttpMethod.Post, s_url); + request.Content = JsonContent.Create(new ClientQueryRequest { Query = "{ __typename }" }); + request.Headers.TryAddWithoutValidation(HeaderNames.Accept, "text/plain"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // assert + Assert.Equal(NotAcceptable, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy, OK)] + [InlineData(Draft20250508, BadRequest)] + [InlineData(Draft20260903, BadRequest)] + public async Task Post_Should_ReturnVaryAccept_When_DocumentCannotBeParsed( + HttpTransportVersion transportVersion, + HttpStatusCode expectedStatusCode) + { + // arrange + var client = GetClient(transportVersion); + + // act + using var response = await client.PostAsync( + s_url, + JsonContent.Create(new ClientQueryRequest { Query = "{" }), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy, NotFound)] + [InlineData(Draft20250508, NotFound)] + [InlineData(Draft20260903, MethodNotAllowed)] + public async Task Get_Should_ReturnVaryAccept_When_GetRequestsAreDisabled( + HttpTransportVersion transportVersion, + HttpStatusCode expectedStatusCode) + { + // arrange + var server = CreateStarWarsServer( + configureServices: s => s.AddGraphQLServer().AddHttpResponseFormatter( + new HttpResponseFormatterOptions + { + HttpTransportVersion = transportVersion + }), + configureConventions: b => b.WithOptions(o => + { + o.EnableGetRequests = false; + o.Tool.Enable = false; + })); + var client = server.CreateClient(); + var query = Uri.EscapeDataString("{ __typename }"); + + // act + using var response = await client.GetAsync( + new Uri($"{s_url}?query={query}"), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(expectedStatusCode, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + + [Theory] + [InlineData(Legacy, "OPTIONS")] + [InlineData(Draft20250508, "OPTIONS")] + [InlineData(Draft20260903, "OPTIONS")] + [InlineData(Legacy, "PUT")] + [InlineData(Draft20250508, "PUT")] + [InlineData(Draft20260903, "PUT")] + public async Task Request_Should_NotReturnVary_When_MethodDoesNotSelectResponseByAccept( + HttpTransportVersion transportVersion, + string method) + { + // arrange + var client = GetClient(transportVersion); + + // act + using var request = new HttpRequestMessage(new HttpMethod(method), s_url); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // assert + Assert.Empty(response.Headers.Vary); + } + + [Fact] + public async Task Upgrade_Should_NotReturnVary_When_WebSocketIsAccepted() + { + // arrange + using var server = CreateStarWarsServer(); + var client = server.CreateWebSocketClient(); + client.SubProtocols.Add("graphql-transport-ws"); + HttpContext? serverContext = null; + client.ConfigureRequest = request => serverContext = request.HttpContext; + + // act + using var webSocket = await client.ConnectAsync( + new Uri("ws://localhost:5000/graphql"), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal("graphql-transport-ws", webSocket.SubProtocol); + Assert.NotNull(serverContext); + Assert.Empty(serverContext.Response.Headers.Vary.ToArray()); + } + + [Fact] + public async Task Post_Should_ReturnVaryAccept_When_EndpointIsMappedWithMapGraphQLHttp() + { + // arrange + var server = CreateServer(endpoints => endpoints.MapGraphQLHttp()); + var client = server.CreateClient(); + + // act + using var response = await client.PostAsync( + s_url, + JsonContent.Create(new ClientQueryRequest { Query = "{ __typename }" }), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } + // When QUERY requests are enabled the endpoint lists the method in Allow and advertises the // body media type it accepts through Accept-Query (RFC 10008, section 3). [Fact] @@ -2430,6 +2683,7 @@ public async Task Query_Should_ReturnUnprocessableContent_When_VariableBatchIsEm .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -2487,6 +2741,7 @@ public async Task Query_Should_ReturnBadRequest_When_BodyHasNoJsonToken( .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -2651,9 +2906,12 @@ public async Task SingleResult_Should_ReturnNotAcceptable_When_EveryMediaTypeIsR .Add(response) .MatchInline( """ + Headers: + Vary: Accept + --------------------------> Status Code: NotAcceptable --------------------------> - + """); } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpContentNegotiationMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpContentNegotiationMiddlewareTests.cs new file mode 100644 index 00000000000..c54078ca58c --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpContentNegotiationMiddlewareTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace HotChocolate.AspNetCore; + +public sealed class HttpContentNegotiationMiddlewareTests +{ + [Theory] + [InlineData("/graphql", "GET", "/graphql")] + [InlineData("/graphql", "HEAD", "/graphql")] + [InlineData("/graphql", "POST", "/graphql")] + [InlineData("/graphql", "QUERY", "/graphql")] + [InlineData("/graphql", "GET", "/graphql/")] + [InlineData("/graphql", "GET", "/GraphQL")] + [InlineData("", "GET", "/")] + [InlineData(null, "GET", "/anything")] + [InlineData(null, "POST", "/")] + public async Task InvokeAsync_Should_AddAcceptToVary_When_EndpointSelectsResponseByAccept( + string? endpointPath, + string method, + string requestPath) + { + // arrange + var context = CreateContext(method, requestPath); + var nextRan = false; + var middleware = CreateMiddleware(endpointPath, () => nextRan = true); + + // act + await middleware.InvokeAsync(context); + + // assert + Assert.Equal(new StringValues("Accept"), context.Response.Headers.Vary); + Assert.True(nextRan); + } + + [Theory] + [InlineData("/graphql", "OPTIONS", "/graphql")] + [InlineData("/graphql", "PUT", "/graphql")] + [InlineData("/graphql", "DELETE", "/graphql")] + [InlineData("/graphql", "GET", "/graphql/nitro-config.json")] + [InlineData("/graphql", "GET", "/graphql-x")] + [InlineData("/graphql", "GET", "/other")] + [InlineData(null, "OPTIONS", "/anything")] + public async Task InvokeAsync_Should_LeaveVaryEmpty_When_EndpointDoesNotSelectResponseByAccept( + string? endpointPath, + string method, + string requestPath) + { + // arrange + var context = CreateContext(method, requestPath); + var nextRan = false; + var middleware = CreateMiddleware(endpointPath, () => nextRan = true); + + // act + await middleware.InvokeAsync(context); + + // assert + Assert.Equal(StringValues.Empty, context.Response.Headers.Vary); + Assert.True(nextRan); + } + + [Fact] + public async Task InvokeAsync_Should_KeepExistingVaryValues_When_VaryIsAlreadySet() + { + // arrange + var context = CreateContext("GET", "/graphql"); + context.Response.Headers.Vary = "Origin"; + var middleware = CreateMiddleware("/graphql", () => { }); + + // act + await middleware.InvokeAsync(context); + + // assert + Assert.Equal(new StringValues(["Origin", "Accept"]), context.Response.Headers.Vary); + } + + private static DefaultHttpContext CreateContext(string method, string requestPath) + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + context.Request.Path = requestPath; + return context; + } + + private static HttpContentNegotiationMiddleware CreateMiddleware( + string? endpointPath, + Action onNext) + => new( + _ => + { + onNext(); + return Task.CompletedTask; + }, + endpointPath is null ? (PathString?)null : new PathString(endpointPath)); +} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetSchemaMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetSchemaMiddlewareTests.cs index a64a37d6345..21694a8d330 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetSchemaMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetSchemaMiddlewareTests.cs @@ -54,6 +54,7 @@ public async Task Download_GraphQL_Schema(string path) // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); response.Headers.Remove("ETag"); + response.Headers.Remove("Vary"); response.Content.Headers.ContentLength = null; response.MatchMarkdownSnapshot(); @@ -83,6 +84,7 @@ public async Task Download_GraphQL_Schema_Slicing_Args_Enabled(string path) // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); response.Headers.Remove("ETag"); + response.Headers.Remove("Vary"); response.Content.Headers.ContentLength = null; response.MatchMarkdownSnapshot(); diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs index b8e0645fd5f..f6f51a54d3a 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs @@ -220,6 +220,7 @@ await server.PostHttpAsync( result.MatchInlineSnapshot( """ Headers: + Vary: Accept abc: def Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> @@ -807,6 +808,7 @@ public async Task Strip_Null_Values_Variant_1() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -842,6 +844,7 @@ public async Task Strip_Null_Values_Variant_2() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -882,6 +885,7 @@ public async Task Strip_Null_Elements() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpQueryMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpQueryMiddlewareTests.cs index cea76f4cccf..24a4cea5bbb 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpQueryMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpQueryMiddlewareTests.cs @@ -34,6 +34,7 @@ public async Task Query_Should_ReturnResult_When_QueryRequestsAreEnabled() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -67,6 +68,7 @@ public async Task Query_Should_ReturnResult_When_VariablesAreSent() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -179,6 +181,7 @@ public async Task Query_Should_ReturnBadRequest_When_BodyIsArray() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -214,6 +217,7 @@ public async Task Query_Should_ReturnBadRequest_When_BodyIsEmptyArray() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -251,6 +255,7 @@ public async Task Query_Should_ReturnBadRequest_When_BatchOperationsIsSet() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -292,6 +297,7 @@ public async Task Query_Should_ReturnBadRequest_When_VariablesIsArray() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -345,6 +351,7 @@ public async Task Query_Should_RunRequestInterceptor_When_RequestIsCreated() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/PersistedOperationMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/PersistedOperationMiddlewareTests.cs index 087a4837020..bd06cef50bc 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/PersistedOperationMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/PersistedOperationMiddlewareTests.cs @@ -266,6 +266,7 @@ public async Task Query_Should_ExecutePersistedOperation_When_QueryRequestsAreEn .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -302,6 +303,7 @@ public async Task Query_Should_ApplyVariables_When_BodyCarriesVariables() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK @@ -335,6 +337,7 @@ public async Task Query_Should_ReturnUnprocessableContent_When_PersistedMutation .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: UnprocessableEntity @@ -371,6 +374,7 @@ public async Task Query_Should_ReturnBadRequest_When_VariablesIsArray() .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest @@ -400,4 +404,35 @@ public async Task Query_Should_ReturnNotFound_When_QueryRequestsAreDisabled() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Empty(response.Content.Headers.Allow); } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("QUERY")] + public async Task Request_Should_ReturnVaryAccept_When_PersistedOperationIsExecuted( + string method) + { + // arrange + var server = CreateStarWarsServer( + configureServices: s => s + .AddGraphQLServer() + .ModifyServerOptions(o => o.EnableQueryRequests = true)); + var client = server.CreateClient(); + + // act + using var request = new HttpRequestMessage( + new HttpMethod(method), + "http://localhost:5000/graphql/persisted/60ddx_GGk4FDObSa6eK0sg/GetHeroName"); + + if (method is not "GET") + { + request.Content = new StringContent("{ }", Encoding.UTF8, "application/json"); + } + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(["Accept"], response.Headers.Vary); + } } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_Development_Success.md b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_Development_Success.md index 1efb9a8fda7..b0452d883a9 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_Development_Success.md +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_Development_Success.md @@ -2,6 +2,7 @@ ```text Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_NOT_Development_Fail.md b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_NOT_Development_Fail.md index 450702931bd..20b61308890 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_NOT_Development_Fail.md +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_When_NOT_Development_Fail.md @@ -2,6 +2,7 @@ ```text Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_With_Rule_Removed_Fail.md b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_With_Rule_Removed_Fail.md index 93f8d7b358f..df59657e722 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_With_Rule_Removed_Fail.md +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/IntrospectionTests.Introspection_Request_With_Rule_Removed_Fail.md @@ -2,6 +2,7 @@ ```text Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs index b564f75e1e6..1334c698606 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs @@ -96,6 +96,7 @@ private static IServiceCollection AddAzureFunctionsGraphQLRequestExecutor( var pipeline = new PipelineBuilder() .Use(MiddlewareFactory.CreateCancellationMiddleware()) .Use(MiddlewareFactory.CreateWebSocketSubscriptionMiddleware(executor, serverOptions)) + .Use(MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path)) .Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions)) .Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions)) .Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions)) diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs index 47c346bd83a..926ec29f998 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Text; using HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; +using HotChocolate.AzureFunctions.Tests.Helpers; using HotChocolate.Types; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Functions.Extensions.DependencyInjection; @@ -93,6 +95,55 @@ public async Task AzFuncIsolatedProcess_FunctionsContextItemsTestAsync() Assert.Equal("Darth Vader", json.data.person.ToString()); } + [Fact] + public async Task Post_Should_ReturnVaryAccept_When_QueryIsExecuted() + { + // arrange + var host = new MockIsolatedProcessHostBuilder() + .AddGraphQLFunction( + b => b.AddQueryType( + d => d.Name("Query").Field("person").Resolve("Luke Skywalker"))) + .Build(); + var requestExecutor = host.Services.GetRequiredService(); + var request = TestHttpRequestDataHelper.NewGraphQLHttpRequestData( + host.Services, + "{ person }"); + + // act + var response = await requestExecutor.ExecuteAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Vary", out var vary)); + Assert.Equal(["Accept"], vary); + } + + [Fact] + public async Task Get_Should_ReturnVaryAccept_When_QueryIsExecuted() + { + // arrange + var host = new MockIsolatedProcessHostBuilder() + .AddGraphQLFunction( + b => b.AddQueryType( + d => d.Name("Query").Field("person").Resolve("Luke Skywalker"))) + .Build(); + var requestExecutor = host.Services.GetRequiredService(); + var request = new MockHttpRequestData( + new MockFunctionContext(host.Services), + HttpMethods.Get, + new Uri( + TestHttpContextHelper.DefaultAzFuncGraphQLUri, + "?query=%7B%20person%20%7D")); + + // act + var response = await requestExecutor.ExecuteAsync(request); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Vary", out var vary)); + Assert.Equal(["Accept"], vary); + } + [Fact] public async Task AzFuncIsolatedProcess_NitroTestAsync() { diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs index 71e154d23d9..3139a620366 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs @@ -1,5 +1,6 @@ using HotChocolate.AzureFunctions.Tests.Helpers; using HotChocolate.Types; +using Microsoft.AspNetCore.Http; using Microsoft.Azure.Functions.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; @@ -39,6 +40,52 @@ public async Task AzFuncInProcess_EndToEndTestAsync() Assert.Equal("Luke Skywalker", json.data.person.ToString()); } + [Fact] + public async Task Post_Should_ReturnVaryAccept_When_QueryIsExecuted() + { + // arrange + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("person").Resolve("Luke Skywalker")); + var requestExecutor = hostBuilder + .BuildServiceProvider() + .GetRequiredService(); + var httpContext = TestHttpContextHelper.NewGraphQLHttpContext("{ person }"); + + // act + await requestExecutor.ExecuteAsync(httpContext.Request); + + // assert + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + Assert.Equal("Accept", httpContext.Response.Headers.Vary.ToString()); + } + + [Fact] + public async Task Get_Should_ReturnVaryAccept_When_QueryIsExecuted() + { + // arrange + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("person").Resolve("Luke Skywalker")); + var requestExecutor = hostBuilder + .BuildServiceProvider() + .GetRequiredService(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Request.Path = TestHttpContextHelper.DefaultAzFuncGraphQLUri.AbsolutePath; + httpContext.Request.QueryString = new QueryString("?query=%7B%20person%20%7D"); + httpContext.Response.Body = new MemoryStream(); + + // act + await requestExecutor.ExecuteAsync(httpContext.Request); + + // assert + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + Assert.Equal("Accept", httpContext.Response.Headers.Vary.ToString()); + } + [Fact] public async Task AzFuncInProcess_NitroTestAsync() { diff --git a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs index 497a6cf5bce..7064ac768a3 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs +++ b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs @@ -413,6 +413,31 @@ public async Task SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine() result.MatchSnapshot(); } + + [Fact] + public async Task SharedMaxAgeAndVary_Should_ListAcceptOnce_When_VaryNamesAccept() + { + // arrange + var server = CreateServer(services => + { + services.AddGraphQLServer() + .UseQueryCache() + .AddCacheControl() + .ModifyCacheControlOptions(o => o.ApplyDefaults = false) + .AddQueryType(d => + d.Name("Query") + .Field("field") + .Resolve("") + .CacheControl(sharedMaxAge: 2000, vary: ["Accept", "X-foo"])); + }); + var client = server.CreateClient(); + + // act + var result = await client.PostQueryAsync("{ field }"); + + // assert + Assert.Equal(["Accept", "x-foo"], result.Headers.Vary); + } } public class GraphQLResult diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Max_Age_Should_Apply_And_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Max_Age_Should_Apply_And_Cache.snap index 1537c24a358..0b34f742f46 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Max_Age_Should_Apply_And_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Max_Age_Should_Apply_And_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Scope_Should_Apply_And_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Scope_Should_Apply_And_Cache.snap index 2cef0413fbf..2a9e8e184fa 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Scope_Should_Apply_And_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Default_Scope_Should_Apply_And_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.JustScope_Should_Not_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.JustScope_Should_Not_Cache.snap index ade977b218d..eb2d9c636c8 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.JustScope_Should_Not_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.JustScope_Should_Not_Cache.snap @@ -1,5 +1,12 @@ { - "Headers": [], + "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + } + ], "ContentHeaders": [ { "Key": "Content-Type", diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Just_Defaults_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Just_Defaults_Should_Cache.snap index 8c2402b5538..048ab5f459c 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Just_Defaults_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.Just_Defaults_Should_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAgeAndScope_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAgeAndScope_Should_Cache.snap index 30e93764014..7b7b742de07 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAgeAndScope_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAgeAndScope_Should_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Combine_Public_Private_Caches_Private.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Combine_Public_Private_Caches_Private.snap index 1b0c1084995..4136bbce255 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Combine_Public_Private_Caches_Private.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Combine_Public_Private_Caches_Private.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Should_Cache_Shortest_Time.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Should_Cache_Shortest_Time.snap index ed213d3da11..bc4b74acfa3 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Should_Cache_Shortest_Time.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Multiple_Should_Cache_Shortest_Time.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_NonZero_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_NonZero_Should_Cache.snap index 663a3206cc0..71a282a92db 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_NonZero_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_NonZero_Should_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_SharedMaxAge_Combine_Produces_Resolved_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_SharedMaxAge_Combine_Produces_Resolved_Cache.snap index af5c26c2539..88bb68dda14 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_SharedMaxAge_Combine_Produces_Resolved_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_SharedMaxAge_Combine_Produces_Resolved_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Zero_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Zero_Should_Cache.snap index 8c2402b5538..048ab5f459c 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Zero_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.MaxAge_Zero_Should_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.No_Applied_Defaults_Should_Not_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.No_Applied_Defaults_Should_Not_Cache.snap index ade977b218d..eb2d9c636c8 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.No_Applied_Defaults_Should_Not_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.No_Applied_Defaults_Should_Not_Cache.snap @@ -1,5 +1,12 @@ { - "Headers": [], + "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + } + ], "ContentHeaders": [ { "Key": "Content-Type", diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.QueryError_Should_Not_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.QueryError_Should_Not_Cache.snap index 802b210230f..d14fb50e890 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.QueryError_Should_Not_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.QueryError_Should_Not_Cache.snap @@ -1,5 +1,12 @@ { - "Headers": [], + "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + } + ], "ContentHeaders": [ { "Key": "Content-Type", diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndScope_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndScope_Should_Cache.snap index 7f2b807a438..02b5434d769 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndScope_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndScope_Should_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine.snap index ff8488a4571..04b69b42d41 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Multiple_Should_Cache_And_Combine.snap @@ -1,18 +1,19 @@ { "Headers": [ - { - "Key": "Cache-Control", - "Value": [ - "s-maxage=1000" - ] - }, { "Key": "Vary", "Value": [ + "Accept", "x-bar", "x-far", "x-foo" ] + }, + { + "Key": "Cache-Control", + "Value": [ + "s-maxage=1000" + ] } ], "ContentHeaders": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Should_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Should_Cache.snap index 7cfa21bfbf6..246ed1648c5 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Should_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAgeAndVary_Should_Cache.snap @@ -1,16 +1,17 @@ { "Headers": [ { - "Key": "Cache-Control", + "Key": "Vary", "Value": [ - "s-maxage=2000" + "Accept", + "x-bar", + "x-foo" ] }, { - "Key": "Vary", + "Key": "Cache-Control", "Value": [ - "x-bar", - "x-foo" + "s-maxage=2000" ] } ], diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_MaxAge_Combine_Produces_Resolved_Cache.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_MaxAge_Combine_Produces_Resolved_Cache.snap index af5c26c2539..88bb68dda14 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_MaxAge_Combine_Produces_Resolved_Cache.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_MaxAge_Combine_Produces_Resolved_Cache.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_Multiple_Combine_Public_Private_Caches_Private.snap b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_Multiple_Combine_Public_Private_Caches_Private.snap index dae75be78e8..5b24282be35 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_Multiple_Combine_Public_Private_Caches_Private.snap +++ b/src/HotChocolate/Caching/test/Caching.Tests/__snapshots__/HttpCachingTests.SharedMaxAge_Multiple_Combine_Public_Private_Caches_Private.snap @@ -1,5 +1,11 @@ { "Headers": [ + { + "Key": "Vary", + "Value": [ + "Accept" + ] + }, { "Key": "Cache-Control", "Value": [ diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableBatchingTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableBatchingTests.cs index c015af6b256..64d605dae97 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableBatchingTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/VariableBatchingTests.cs @@ -169,6 +169,7 @@ public async Task Execute_Should_ReturnRequestError_When_VariableBatchIsEmpty( .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -223,6 +224,7 @@ public async Task Execute_Should_ReturnInternalServerError_When_CoercedVariables .MatchInline( """ Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: InternalServerError diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp200_When_AcceptIsLegacyJson.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp200_When_AcceptIsLegacyJson.snap index 2f11b1e9bc4..6a4e191e721 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp200_When_AcceptIsLegacyJson.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp200_When_AcceptIsLegacyJson.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp400_When_AcceptIsGraphQLResponseJson.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp400_When_AcceptIsGraphQLResponseJson.snap index ef92e45e90d..cb2aa357d93 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp400_When_AcceptIsGraphQLResponseJson.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/CostReportingTests.RejectedRequest_Should_ReturnHttp400_When_AcceptIsGraphQLResponseJson.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_OperationPlanHeader_Should_Omit_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_OperationPlanHeader_Should_Omit_OperationPlan.snap index 3b59182c2b5..ee5de0c0ecb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_OperationPlanHeader_Should_Omit_OperationPlan.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_OperationPlanHeader_Should_Omit_OperationPlan.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap index 9e9a4528906..9d71b2a795f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_And_OperationPlanHeader_Should_Include_OperationPlan.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap index 3b59182c2b5..ee5de0c0ecb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_False_With_PerRequestOverride_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap index 9e9a4528906..9d71b2a795f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_With_OperationPlanHeader_Should_Include_OperationPlan.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap index 3b59182c2b5..ee5de0c0ecb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.AllowOperationPlanRequests_True_Without_OperationPlanHeader_Should_Omit_OperationPlan.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_NotFound.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_NotFound.snap index a62e20834e6..a188806b7f3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_NotFound.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_NotFound.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_MD5Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha1Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha1Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha1Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha1Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha256Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha256Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha256Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.ApolloStyle_Sha256Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_NotFound.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_NotFound.snap index a62e20834e6..a188806b7f3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_NotFound.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_NotFound.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_MD5Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha1Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha1Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha1Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha1Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Query_Empty_String_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Query_Empty_String_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Query_Empty_String_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Query_Empty_String_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Success.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Success.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Success.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.HotChocolateStyle_Sha256Hash_Success.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Allowed_When_Persisted.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Allowed_When_Persisted.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Allowed_When_Persisted.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Allowed_When_Persisted.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_By_Default_Works.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_By_Default_Works.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_By_Default_Works.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_By_Default_Works.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed.snap index cab2a6bc158..f0f4ca69e06 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Custom_Error.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Custom_Error.snap index 9e07a2f23b7..e1ac8e1f716 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Custom_Error.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Custom_Error.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Even_When_Persisted.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Even_When_Persisted.snap index cab2a6bc158..f0f4ca69e06 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Even_When_Persisted.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Even_When_Persisted.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: BadRequest diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Override_Per_Request.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Override_Per_Request.snap index 14447b4c81a..3682a5bfe2b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Override_Per_Request.snap +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/PersistedOperationTests.Standard_Query_Not_Allowed_Override_Per_Request.snap @@ -1,4 +1,5 @@ Headers: +Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: OK diff --git a/website/content/docs/fusion/cache-control.md b/website/content/docs/fusion/cache-control.md index 013654c53eb..95acb34fcef 100644 --- a/website/content/docs/fusion/cache-control.md +++ b/website/content/docs/fusion/cache-control.md @@ -93,7 +93,7 @@ type Query { Fusion computes one effective response policy by traversing the planned operation tree. It starts at the selected root fields, reads `@cacheControl` metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections, including interfaces and unions. -All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. +All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. Every GraphQL response already lists `Accept` in `Vary`, and the merged `vary` names are added to it. Some CDNs, Akamai by default, do not cache a response whose `Vary` lists anything other than `Accept-Encoding`. Configure the CDN to cache it anyway, or remove `Accept` from `Vary` in a [custom formatter](../hotchocolate/server/http-transport.md#adding-selecting-headers-to-vary). Fusion computes these cache constraints for query operations. Mutation requests, Subscription request, introspection requests, and operations with no cache constraints do not get cache-control headers. diff --git a/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md b/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md index 35b536c2e6e..a70e92d7640 100644 --- a/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md +++ b/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md @@ -265,6 +265,14 @@ A `406` has no `Content-Type` and no body when the header also rejects the forma A client that relies on the 16.6 format keeps it by rating that format highest in its `Accept` header. +## Responses carry `Vary: Accept` + +Every response to a GET, HEAD, POST, or QUERY request on a GraphQL endpoint lists `Accept` in its `Vary` header, whatever its status code, under every transport version. This covers `MapGraphQL`, `MapGraphQLHttp`, `MapGraphQLPersistedOperations`, and the Azure Functions integration. 16.6 did not list `Accept` in `Vary`. + +The `vary` names of `@cacheControl` are added beside `Accept`. 16.6 wrote them in place of any `Vary` value that application middleware had set earlier in the request. + +A CDN or reverse proxy that honors `Vary` stores one cached response per distinct `Accept` value. Some CDNs do not cache a response whose `Vary` lists anything other than `Accept-Encoding`. Akamai behaves this way by default, so cached GET responses stop being cached at the edge after the update. Configure the CDN to cache them anyway, for example with Akamai's [Remove Vary Header](https://techdocs.akamai.com/property-mgr/docs/rm-vary-header) behavior, or remove `Accept` from `Vary` in a [custom formatter](../server/http-transport.md#adding-selecting-headers-to-vary). + # Noteworthy changes ## New cost options diff --git a/website/content/docs/hotchocolate/server/cache-control.md b/website/content/docs/hotchocolate/server/cache-control.md index 54e29e99f7b..224c7a4d01e 100644 --- a/website/content/docs/hotchocolate/server/cache-control.md +++ b/website/content/docs/hotchocolate/server/cache-control.md @@ -111,7 +111,7 @@ public static class Query Hot Chocolate computes one effective response policy by traversing the selected query fields. It reads `@cacheControl` metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections. -All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. +All collected constraints are merged into one final policy. The merge is conservative: `max-age` and `s-maxage` take the lowest value, scope resolves to the strictest value (`private` over `public`), and `vary` values are merged, normalized, and deduplicated. Every GraphQL response already lists `Accept` in `Vary`, and the merged `vary` names are added to it. Some CDNs, Akamai by default, do not cache a response whose `Vary` lists anything other than `Accept-Encoding`. Configure the CDN to cache it anyway, or remove `Accept` from `Vary` in a [custom formatter](./http-transport.md#adding-selecting-headers-to-vary). Hot Chocolate computes cache constraints only for query operations. Introspection requests and operations for which no selected field contributes `maxAge` or `sharedMaxAge` do not produce a cache policy. `UseQueryCache()` writes the final headers only when the executed result has no GraphQL errors and the request has not opted out of cache-control header generation. diff --git a/website/content/docs/hotchocolate/server/http-transport.md b/website/content/docs/hotchocolate/server/http-transport.md index 05a69b6f302..d8ef34344ca 100644 --- a/website/content/docs/hotchocolate/server/http-transport.md +++ b/website/content/docs/hotchocolate/server/http-transport.md @@ -22,6 +22,8 @@ A single result is written as `multipart/mixed` or `text/event-stream` only when When the client sends `Accept: application/json`, the response `Content-Type` is `application/json`. Under `Draft20250508`, the default transport version, every request the server reads is then answered with a `200` status code, including one that fails validation or asks for an operation kind the request method does not allow; only a request it cannot read, such as a body that is not valid JSON or a request that is not a well-formed GraphQL over HTTP request, and a batch it does not accept have a `400` status code. Under `Draft20260903`, the response takes the same status code as `application/graphql-response+json`, and only a `2xx` response carries `Content-Type: application/json`. +Every response to a GET, HEAD, POST, or QUERY request on the GraphQL endpoint carries `Vary: Accept`, whatever its status code. A cache that honors `Vary` stores one response per distinct `Accept` value. + # Types of Requests GraphQL requests over HTTP can be performed via the POST, GET, or QUERY HTTP verb. @@ -200,6 +202,26 @@ public class CustomHttpResponseFormatter : DefaultHttpResponseFormatter } ``` +## Adding Selecting Headers to Vary + +The endpoint adds `Accept` to the `Vary` header before the formatter runs. A custom formatter that selects a response by further request headers adds them to `Vary` without replacing it: + +```csharp +public class CustomHttpResponseFormatter : DefaultHttpResponseFormatter +{ + protected override void OnWriteResponseHeaders( + OperationResult result, + FormatInfo format, + IHeaderDictionary headers) + { + headers.Append(HeaderNames.Vary, HeaderNames.AcceptLanguage); + base.OnWriteResponseHeaders(result, format, headers); + } +} +``` + +A formatter can also remove `Accept` from `Vary` in the same method, keeping the other names in the header, for a cache that does not store responses whose `Vary` lists `Accept`. + # JSON Serialization You can alter some JSON serialization settings when configuring the `HttpResponseFormatter`. From abb445268df5805265620427eea492fb640a0e09 Mon Sep 17 00:00:00 2001 From: Glen Date: Fri, 2 Oct 2026 17:06:31 +0200 Subject: [PATCH 2/3] Handle `Vary: *`, exclude WebSocket upgrades in docs, fix snapshots --- .../AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs | 5 +++-- .../Extensions/HttpResponseExtensionsTests.cs | 2 ++ .../test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs | 2 ++ .../docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md | 2 +- website/content/docs/hotchocolate/server/http-transport.md | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs index 408ce9256a8..c5c0ff89a96 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpResponseExtensions.cs @@ -12,7 +12,7 @@ internal static class HttpResponseExtensions /// /// Adds each comma-separated field name in to the Vary - /// header unless the header already lists it, compared case-insensitively. + /// header unless the header already lists it, compared case-insensitively, or lists *. /// public static void AppendVary(this IHeaderDictionary headers, string fieldNames) { @@ -73,7 +73,8 @@ private static bool ListsFieldName(StringValues values, ReadOnlySpan field while (TryReadFieldName(ref remaining, out var listedFieldName)) { - if (listedFieldName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) + if (listedFieldName is "*" + || listedFieldName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs index 96856df9b7b..ff07ec4fe91 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs @@ -11,6 +11,8 @@ public sealed class HttpResponseExtensionsTests [InlineData(new[] { "Accept" }, "x-bar, x-foo", new[] { "Accept", "x-bar, x-foo" })] [InlineData(new[] { "Accept" }, "accept, x-foo", new[] { "Accept", "x-foo" })] [InlineData(new[] { "Origin, Accept" }, "ACCEPT", new[] { "Origin, Accept" })] + [InlineData(new[] { "*" }, "Accept", new[] { "*" })] + [InlineData(new[] { "Origin", "*" }, "Accept, x-foo", new[] { "Origin", "*" })] public void AppendVary_Should_AddOnlyNamesNotYetListed_When_NamesAreGiven( string[] existing, string fieldNames, diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs index d0a4f856d34..4ffcd80442c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs @@ -1844,6 +1844,7 @@ public async Task Post_Should_ReturnUnprocessableContent_When_PersistedVariableB .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} @@ -1876,6 +1877,7 @@ public async Task Get_Should_ReturnUnprocessableContent_When_PersistedVariableBa .MatchInline( $$$""" Headers: + Vary: Accept Content-Type: application/graphql-response+json; charset=utf-8 --------------------------> Status Code: {{{expectedStatusCode}}} diff --git a/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md b/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md index a70e92d7640..bc2c98af525 100644 --- a/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md +++ b/website/content/docs/hotchocolate/migrating/migrate-from-16-6-to-16-7.md @@ -267,7 +267,7 @@ A client that relies on the 16.6 format keeps it by rating that format highest i ## Responses carry `Vary: Accept` -Every response to a GET, HEAD, POST, or QUERY request on a GraphQL endpoint lists `Accept` in its `Vary` header, whatever its status code, under every transport version. This covers `MapGraphQL`, `MapGraphQLHttp`, `MapGraphQLPersistedOperations`, and the Azure Functions integration. 16.6 did not list `Accept` in `Vary`. +Every response to a GET, HEAD, POST, or QUERY request on a GraphQL endpoint, other than a WebSocket upgrade, lists `Accept` in its `Vary` header, whatever its status code, under every transport version. This covers `MapGraphQL`, `MapGraphQLHttp`, `MapGraphQLPersistedOperations`, and the Azure Functions integration. 16.6 did not list `Accept` in `Vary`. The `vary` names of `@cacheControl` are added beside `Accept`. 16.6 wrote them in place of any `Vary` value that application middleware had set earlier in the request. diff --git a/website/content/docs/hotchocolate/server/http-transport.md b/website/content/docs/hotchocolate/server/http-transport.md index d8ef34344ca..aa40f120cdf 100644 --- a/website/content/docs/hotchocolate/server/http-transport.md +++ b/website/content/docs/hotchocolate/server/http-transport.md @@ -22,7 +22,7 @@ A single result is written as `multipart/mixed` or `text/event-stream` only when When the client sends `Accept: application/json`, the response `Content-Type` is `application/json`. Under `Draft20250508`, the default transport version, every request the server reads is then answered with a `200` status code, including one that fails validation or asks for an operation kind the request method does not allow; only a request it cannot read, such as a body that is not valid JSON or a request that is not a well-formed GraphQL over HTTP request, and a batch it does not accept have a `400` status code. Under `Draft20260903`, the response takes the same status code as `application/graphql-response+json`, and only a `2xx` response carries `Content-Type: application/json`. -Every response to a GET, HEAD, POST, or QUERY request on the GraphQL endpoint carries `Vary: Accept`, whatever its status code. A cache that honors `Vary` stores one response per distinct `Accept` value. +Every response to a GET, HEAD, POST, or QUERY request on the GraphQL endpoint, other than a WebSocket upgrade, carries `Vary: Accept`, whatever its status code. A cache that honors `Vary` stores one response per distinct `Accept` value. # Types of Requests From 52fae2933063f192c714a17c43954af592c45a3f Mon Sep 17 00:00:00 2001 From: Glen Date: Fri, 2 Oct 2026 17:40:27 +0200 Subject: [PATCH 3/3] Fix isolated Azure Functions duplicating a header that is set again --- .../Extensions/HttpResponseExtensionsTests.cs | 1 + .../AzureHeaderDictionary.cs | 2 + ...late.AzureFunctions.IsolatedProcess.csproj | 4 ++ .../AzureHeaderDictionaryTests.cs | 45 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/AzureHeaderDictionaryTests.cs diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs index ff07ec4fe91..71c13c065bf 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Extensions/HttpResponseExtensionsTests.cs @@ -11,6 +11,7 @@ public sealed class HttpResponseExtensionsTests [InlineData(new[] { "Accept" }, "x-bar, x-foo", new[] { "Accept", "x-bar, x-foo" })] [InlineData(new[] { "Accept" }, "accept, x-foo", new[] { "Accept", "x-foo" })] [InlineData(new[] { "Origin, Accept" }, "ACCEPT", new[] { "Origin, Accept" })] + [InlineData(new[] { "Origin, , Accept" }, "accept", new[] { "Origin, , Accept" })] [InlineData(new[] { "*" }, "Accept", new[] { "*" })] [InlineData(new[] { "Origin", "*" }, "Accept, x-foo", new[] { "Origin", "*" })] public void AppendVary_Should_AddOnlyNamesNotYetListed_When_NamesAreGiven( diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/AzureHeaderDictionary.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/AzureHeaderDictionary.cs index bcb3737108e..ffb8ce3737d 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/AzureHeaderDictionary.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/AzureHeaderDictionary.cs @@ -75,6 +75,7 @@ public StringValues this[string key] set { _response.Headers[key] = value; + _responseData.Headers.Remove(key); _responseData.Headers.Add(key, (IEnumerable)value); } } @@ -85,6 +86,7 @@ public long? ContentLength set { _response.Headers.ContentLength = value; + _responseData.Headers.Remove(HeaderNames.ContentLength); _responseData.Headers.Add( HeaderNames.ContentLength, (string?)_response.Headers[HeaderNames.ContentLength]); diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj index 9e073c63f46..43d73457780 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions.IsolatedProcess/HotChocolate.AzureFunctions.IsolatedProcess.csproj @@ -7,6 +7,10 @@ This package contains the GraphQL AzureFunctions Isolated Process integration for Hot Chocolate. + + + + diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/AzureHeaderDictionaryTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/AzureHeaderDictionaryTests.cs new file mode 100644 index 00000000000..85943f62001 --- /dev/null +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/AzureHeaderDictionaryTests.cs @@ -0,0 +1,45 @@ +using HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; + +namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests; + +public sealed class AzureHeaderDictionaryTests +{ + [Fact] + public void Indexer_Should_ReplaceResponseDataValues_When_HeaderIsSetAgain() + { + // arrange + using var functionContext = new MockFunctionContext( + new ServiceCollection().BuildServiceProvider()); + using var responseData = new MockHttpResponseData(functionContext); + var headers = new AzureHeaderDictionary(new DefaultHttpContext().Response, responseData); + headers["Vary"] = "Accept"; + + // act + headers["Vary"] = new StringValues(["Accept", "x-foo"]); + + // assert + Assert.Equal(["Accept", "x-foo"], responseData.Headers.GetValues("Vary")); + } + + [Fact] + public void ContentLength_Should_ReplaceResponseDataValue_When_ContentLengthIsSetAgain() + { + // arrange + using var functionContext = new MockFunctionContext( + new ServiceCollection().BuildServiceProvider()); + using var responseData = new MockHttpResponseData(functionContext); + var headers = new AzureHeaderDictionary(new DefaultHttpContext().Response, responseData) + { + ContentLength = 10 + }; + + // act + headers.ContentLength = 20; + + // assert + Assert.Equal(["20"], responseData.Headers.GetValues("Content-Length")); + } +}