diff --git a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs index 7064ac768a3..de60809fe84 100644 --- a/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs +++ b/src/HotChocolate/Caching/test/Caching.Tests/HttpCachingTests.cs @@ -365,12 +365,15 @@ public async Task SharedMaxAgeAndScope_Should_Cache() result.MatchSnapshot(); } - [Fact] - public async Task SharedMaxAgeAndVary_Should_Cache() + [Theory] + [InlineData("POST")] + [InlineData("QUERY")] + public async Task SharedMaxAgeAndVary_Should_Cache(string method) { var server = CreateServer(services => { services.AddGraphQLServer() + .ModifyServerOptions(o => o.EnableQueryRequests = true) .UseQueryCache() .AddCacheControl() .ModifyCacheControlOptions(o => o.ApplyDefaults = false) @@ -382,7 +385,7 @@ public async Task SharedMaxAgeAndVary_Should_Cache() }); var client = server.CreateClient(); - var result = await client.PostQueryAsync("{ field }"); + var result = await client.SendQueryAsync(new HttpMethod(method), "{ field }"); result.MatchSnapshot(); } @@ -451,13 +454,22 @@ public class GraphQLResult internal static class TestServerExtensions { - public static async Task PostQueryAsync(this HttpClient client, string query) + public static Task PostQueryAsync(this HttpClient client, string query) + => client.SendQueryAsync(HttpMethod.Post, query); + + public static async Task SendQueryAsync( + this HttpClient client, + HttpMethod method, + string query) { var payload = $"{{ \"query\": \"{query}\" }}"; - var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(method, "/graphql") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; - var response = await client.PostAsync("/graphql", content); + var response = await client.SendAsync(request); var result = new GraphQLResult { diff --git a/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs index 75a5ce7de58..ab75da3cc4b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Caching.Tests/FusionCachingTests.cs @@ -86,8 +86,10 @@ public async Task Query_With_Private_Scope_Should_Return_Private_Header() Assert.Equal(TimeSpan.FromSeconds(3600), response.Headers.CacheControl.MaxAge); } - [Fact] - public async Task Query_With_SharedMaxAge_Should_Return_SMaxAge_Header() + [Theory] + [InlineData("POST")] + [InlineData("QUERY")] + public async Task Query_With_SharedMaxAge_Should_Return_SMaxAge_Header(string method) { // arrange using var server = CreateSourceSchema( @@ -99,12 +101,13 @@ public async Task Query_With_SharedMaxAge_Should_Return_SMaxAge_Header() using var gateway = await CreateCompositeSchemaAsync( [("a", server)], configureGatewayBuilder: b => b + .ModifyServerOptions(o => o.EnableQueryRequests = true) .AddCacheControl() .UseQueryCache()); // act var httpClient = gateway.CreateClient(); - var response = await httpClient.PostGraphQLAsync("{ sharedData }"); + var response = await httpClient.SendGraphQLAsync(new HttpMethod(method), "{ sharedData }"); // assert Assert.NotNull(response.Headers.CacheControl); @@ -295,11 +298,20 @@ public class Mutation internal static class TestHttpClientExtensions { - public static async Task PostGraphQLAsync( - this HttpClient client, string query) + public static Task PostGraphQLAsync(this HttpClient client, string query) + => client.SendGraphQLAsync(HttpMethod.Post, query); + + public static async Task SendGraphQLAsync( + this HttpClient client, + HttpMethod method, + string query) { var payload = $$"""{"query":"{{query}}"}"""; - var content = new StringContent(payload, Encoding.UTF8, "application/json"); - return await client.PostAsync("/graphql", content); + using var request = new HttpRequestMessage(method, "/graphql") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + return await client.SendAsync(request); } } diff --git a/website/content/docs/fusion/cache-control.md b/website/content/docs/fusion/cache-control.md index 95acb34fcef..e00f2837b39 100644 --- a/website/content/docs/fusion/cache-control.md +++ b/website/content/docs/fusion/cache-control.md @@ -16,7 +16,7 @@ Good cache rules help you: To make caching work safely and predictably, you need two things: -1. A deterministic GET route so caches get a stable cache key. +1. A deterministic GET route, or a QUERY request, so caches get a stable cache key. 2. Correct Cache-Control (and Vary) headers so caches know where and how long a response may be reused. # Why GraphQL Needs Extra Care @@ -74,6 +74,29 @@ var app = builder.Build(); app.MapGraphQLPersistedOperations(); ``` +# QUERY Requests + +Query operations can also be sent over HTTP QUERY, which carries the same JSON body as a POST request. QUERY requests are disabled by default. Enable them on the gateway with `EnableQueryRequests`: + +```csharp +builder + .AddGraphQLGateway() + .ModifyServerOptions(o => o.EnableQueryRequests = true); +``` + +```http +QUERY /graphql +Content-Type: application/json +Accept: application/graphql-response+json + +{ + "query": "query GetProducts($first: Int!) { products(first: $first) { nodes { name } } }", + "variables": { "first": 5 } +} +``` + +[RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html#section-2.7) makes the response to a QUERY request cacheable, and a cache that stores it includes the request body in the cache key. A QUERY response carries the same `Cache-Control` and `Vary` headers as a GET response. Support for caching QUERY responses varies between CDNs and proxies, so GET routes and persisted-operation GET routes are the portable choice. See [QUERY Requests](../hotchocolate/server/http-transport.md#query-requests) for the full request rules. + # `@cacheControl` in GraphQL A deterministic route alone is not enough. The gateway also needs policy metadata to decide whether a response is public or private, and how long it may be reused. diff --git a/website/content/docs/hotchocolate/server/cache-control.md b/website/content/docs/hotchocolate/server/cache-control.md index 224c7a4d01e..001e10b630e 100644 --- a/website/content/docs/hotchocolate/server/cache-control.md +++ b/website/content/docs/hotchocolate/server/cache-control.md @@ -16,7 +16,7 @@ Good cache rules help you: To make caching work safely and predictably, you need two things: -1. A deterministic GET route so caches get a stable cache key. +1. A deterministic GET route, or a QUERY request, so caches get a stable cache key. 2. Correct Cache-Control (and Vary) headers so caches know where and how long a response may be reused. # Why GraphQL Needs Extra Care @@ -74,6 +74,29 @@ var app = builder.Build(); app.MapGraphQLPersistedOperations(); ``` +# QUERY Requests + +Query operations can also be sent over HTTP QUERY, which carries the same JSON body as a POST request. QUERY requests are disabled by default. Enable them with `EnableQueryRequests`: + +```csharp +builder + .AddGraphQL() + .ModifyServerOptions(o => o.EnableQueryRequests = true); +``` + +```http +QUERY /graphql +Content-Type: application/json +Accept: application/graphql-response+json + +{ + "query": "query GetProducts($first: Int!) { products(first: $first) { nodes { name } } }", + "variables": { "first": 5 } +} +``` + +[RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html#section-2.7) makes the response to a QUERY request cacheable, and a cache that stores it includes the request body in the cache key. A QUERY response carries the same `Cache-Control` and `Vary` headers as a GET response. Support for caching QUERY responses varies between CDNs and proxies, so GET routes and persisted-operation GET routes are the portable choice. See [QUERY Requests](./http-transport.md#query-requests) for the full request rules. + # `@cacheControl` in GraphQL A deterministic route alone is not enough. The gateway also needs policy metadata to decide whether a response is public or private, and how long it may be reused.