From 0aa84c214a76fd2c4d107afec1a6b90b479684cb Mon Sep 17 00:00:00 2001 From: Glen Date: Tue, 6 Oct 2026 10:25:34 +0200 Subject: [PATCH] Read `documentId` from GraphQL GET requests --- .../AspNetCore.Pipeline/HttpGetMiddleware.cs | 3 +- .../Parsers/DefaultHttpRequestParser.cs | 25 ++++--- .../HttpGetMiddlewareTests.cs | 28 ++++++++ .../DefaultHttpRequestParserTests.cs | 68 +++++++++++++++++++ .../migrating/migrate-from-16-6-to-16-7.md | 4 ++ .../performance/trusted-documents.md | 10 ++- 6 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpGetMiddleware.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpGetMiddleware.cs index 2e0acea37ae..6c246b74bdc 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpGetMiddleware.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HttpGetMiddleware.cs @@ -30,7 +30,8 @@ public async Task InvokeAsync(HttpContext context) // Verify that the request is relevant to this middleware. && (context.Request.Query.ContainsKey(QueryKey) - || context.Request.Query.ContainsKey(QueryIdKey) + || context.Request.Query.ContainsKey(IdKey) + || context.Request.Query.ContainsKey(DocumentIdKey) || context.Request.Query.ContainsKey(ExtensionsKey)) // Allow ALL GET requests if we do NOT enforce preflight diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Parsers/DefaultHttpRequestParser.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Parsers/DefaultHttpRequestParser.cs index fb705a6689f..783402c9337 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Parsers/DefaultHttpRequestParser.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Parsers/DefaultHttpRequestParser.cs @@ -15,7 +15,8 @@ namespace HotChocolate.AspNetCore.Parsers; internal sealed class DefaultHttpRequestParser : IHttpRequestParser { internal const int MinRequestSize = 256; - internal const string QueryIdKey = "id"; + internal const string DocumentIdKey = "documentId"; + internal const string IdKey = "id"; private const string OperationNameKey = "operationName"; private const string OnErrorKey = "onError"; internal const string QueryKey = "query"; @@ -206,13 +207,19 @@ public GraphQLRequest ParseRequestFromParams(IQueryCollection parameters, bool s query = parameters[QueryKey]; } - string? queryId = parameters[QueryIdKey]; + string? documentId = parameters[IdKey]; + + if (string.IsNullOrWhiteSpace(documentId)) + { + documentId = parameters[DocumentIdKey]; + } + string? operationName = parameters[OperationNameKey]; string? onError = parameters[OnErrorKey]; JsonDocument? extensions = null; - // if we have no query or query id, we cannot execute anything. - if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(queryId)) + // if we have no query or document ID, we cannot execute anything. + if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(documentId)) { // so, if we do not find a top-level query or top-level id, we will try to parse // the extensions and look in the extensions for Apollo's active persisted @@ -247,14 +254,14 @@ public GraphQLRequest ParseRequestFromParams(IQueryCollection parameters, bool s } } - // if we however found a query hash, we will use it as a query id and move on + // if we however found a query hash, we will use it as a document ID and move on // to execute the query. - queryId = hash; + documentId = hash; } - if (!string.IsNullOrWhiteSpace(queryId)) + if (!string.IsNullOrWhiteSpace(documentId)) { - EnsureValidDocumentId(queryId); + EnsureValidDocumentId(documentId); } try @@ -285,7 +292,7 @@ public GraphQLRequest ParseRequestFromParams(IQueryCollection parameters, bool s return new GraphQLRequest( document, - queryId, + documentId, documentHash, operationName, errorHandlingMode, diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetMiddlewareTests.cs index d9a37ca423c..110957ce0bc 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpGetMiddlewareTests.cs @@ -749,6 +749,34 @@ await server.GetStoreActivePersistedQueryAsync( new[] { resultA, resultB }.MatchSnapshot(); } + [Fact] + public async Task Get_Should_ExecutePersistedOperation_When_DocumentIdIsSet() + { + // arrange + var server = CreateStarWarsServer(); + var client = server.CreateClient(); + + // act + using var response = await client.GetAsync( + "http://localhost:5000/graphql?documentId=60ddx_GGk4FDObSa6eK0sg", + TestContext.Current.CancellationToken); + + // assert + Snapshot + .Create() + .Add(response) + .MatchInline( + """ + Headers: + Vary: Accept + Content-Type: application/graphql-response+json; charset=utf-8 + --------------------------> + Status Code: OK + --------------------------> + {"data":{"hero":{"name":"R2-D2"}}} + """); + } + [Fact] public async Task Throw_Custom_GraphQL_Error() { diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Serialization/DefaultHttpRequestParserTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Serialization/DefaultHttpRequestParserTests.cs index 7490d384087..11cb20c0695 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Serialization/DefaultHttpRequestParserTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/Serialization/DefaultHttpRequestParserTests.cs @@ -172,6 +172,74 @@ void Parse() Assert.Equal("The operation ID has an invalid format.", ex.Message); } + [Fact] + public void ParseRequestFromParams_Should_ReadDocumentId_When_IdIsMissing() + { + // arrange + var queryParams = new QueryCollection( + new Dictionary + { + ["documentId"] = "abc1213_5164-ABC-123" + }); + var parser = new DefaultHttpRequestParser( + new DefaultDocumentCache(), + new Sha256DocumentHashProvider(), + 256, + ParserOptions.Default); + + // act + var request = parser.ParseRequestFromParams(queryParams); + + // assert + Assert.Equal("abc1213_5164-ABC-123", request.DocumentId?.Value); + } + + [Fact] + public void ParseRequestFromParams_Should_PreferId_When_IdAndDocumentIdAreSet() + { + // arrange + var queryParams = new QueryCollection( + new Dictionary + { + ["documentId"] = "def456", + ["id"] = "abc123" + }); + var parser = new DefaultHttpRequestParser( + new DefaultDocumentCache(), + new Sha256DocumentHashProvider(), + 256, + ParserOptions.Default); + + // act + var request = parser.ParseRequestFromParams(queryParams); + + // assert + Assert.Equal("abc123", request.DocumentId?.Value); + } + + [Fact] + public void ParseRequestFromParams_Should_Throw_When_DocumentIdHasInvalidFormat() + { + // arrange + var queryParams = new QueryCollection( + new Dictionary + { + ["documentId"] = "sha256:abc123" + }); + var parser = new DefaultHttpRequestParser( + new DefaultDocumentCache(), + new Sha256DocumentHashProvider(), + 256, + ParserOptions.Default); + + // act + void Parse() => parser.ParseRequestFromParams(queryParams); + + // assert + var ex = Assert.Throws(Parse); + Assert.Equal("The operation ID has an invalid format.", ex.Message); + } + [Fact] public async Task ParseRequestAsync_Should_AdvancePipe_When_SyntaxError() { 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 b7081464248..e6cdd1eab4f 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 @@ -326,3 +326,7 @@ Cost rejections, including the single result for a rejected variable batch, retu Request batching is an array of independent requests in one HTTP request. Cost limits currently apply separately to each independent request in a request batch. Summing costs across an entire request batch is planned, with no target version. Use `#!csharp RequestContext.TryGetCostAnalysisResult(out var result)` to access the compiled `CostPlan` and all estimates for the request. Cost analysis and reporting return `HC0048` when required operation or document state is missing, when a request reaches the analyzer with zero coerced variable sets (an empty variable batch executed through `IRequestExecutor`; over HTTP, the transport refuses `variables: []` before execution), or when metrics cannot be attached to the execution-result state. + +## Persisted operations over GET accept `documentId` + +A GET request on a GraphQL endpoint reads the operation document ID from a `documentId` query parameter as well as from `id`, the two names a POST body already accepts. A GET request that carries both uses `id`. This covers `MapGraphQL`, `MapGraphQLHttp`, and the Azure Functions integration, under every transport version. 16.6 did not execute a GET request that carried only `documentId`, so it was served Nitro or got a `404` status code. diff --git a/website/content/docs/hotchocolate/performance/trusted-documents.md b/website/content/docs/hotchocolate/performance/trusted-documents.md index 8b9e6fe5008..4b3407b0bc3 100644 --- a/website/content/docs/hotchocolate/performance/trusted-documents.md +++ b/website/content/docs/hotchocolate/performance/trusted-documents.md @@ -228,7 +228,7 @@ In the above example, requests containing the `X-Developer` header can execute d # Client Expectations -A client is expected to send an `id` field containing the operation document hash instead of a `query` field. +A client is expected to send an `id` or `documentId` field containing the operation document hash instead of a `query` field. Over HTTP GET, the same names are query parameters, and a request that carries both uses `id`. **HTTP POST** @@ -241,8 +241,14 @@ A client is expected to send an `id` field containing the operation document has } ``` +**HTTP GET** + +```http +GET /graphql?id=0c95d31ca29272475bf837f944f4e513&variables={"first":5} +``` + > [!NOTE] -> [Relay's persisted queries documentation](https://relay.dev/docs/guides/persisted-queries/#network-layer-changes) uses `doc_id` instead of `id`. Be sure to change it to `id`. +> [Relay's persisted queries documentation](https://relay.dev/docs/guides/persisted-queries/#network-layer-changes) uses `doc_id` instead of `id`. Be sure to change it to `id` or `documentId`. # Next Steps