From cf4402263ad093ff0320800b7882b87b4307da61 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 3 Oct 2026 13:10:47 +0200 Subject: [PATCH 1/3] Fixed automatic persisted query requests being answered with the schema Apollo Client's persisted query link first sends only a hash of the query in extensions.persistedQuery and falls back to the full query once the server answers PersistedQueryNotSupported. The HTTP handler ignored the extension: such a GET request was answered with the introspection result, a POST request without a query with a 400 "Invalid JSON body" problem, and a request carrying both was executed, so the client never received the error it expects. - Read the extensions through an internal GQLRequestEnvelope before binding GQLRequestContent, so the public request type is unchanged, and check the extensions query string parameter of GET requests. - Answer a request whose extensions.persistedQuery is truthy, with or without a query, as Apollo Server 5.5.1 does with persisted queries disabled (requestPipeline.ts, internalErrorClasses.ts): HTTP 200, Cache-Control "private, no-cache, must-revalidate", a single PersistedQueryNotSupported error with extensions.code PERSISTED_QUERY_NOT_SUPPORTED and no data. - Left graphql-transport-ws subscribe payloads unchecked: they must carry the query, are never answered with introspection, and GraphQLWsLink sends the full query together with the operation's extensions. - Breaking change: a request with both a query and a persisted query hash is now rejected instead of executed. Co-Authored-By: Claude Opus 5.5 --- RELEASE_NOTES.md | 2 + .../GraphQLRequestHandler.fs | 145 ++++++++++ .../RequestHandlerExtensibilityTests.fs | 269 ++++++++++++++++-- 3 files changed, 400 insertions(+), 16 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ec961106..1172cb7b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -310,6 +310,8 @@ * Added `GraphQLTransportWS.SubProtocol`, the `graphql-transport-ws` sub-protocol name * Fixed `graphql-transport-ws` delivery of `@defer` and `@stream` results, which are now sent as soon as they are produced instead of after a fixed 5 second delay, followed by a final payload with `hasNext: false` * Changed `graphql-transport-ws` incremental delivery of `@defer` and `@stream` results to the `pending`/`incremental`/`completed`/`hasNext` wire format used by graphql-js 17 and Apollo Client's `GraphQL17Alpha9Handler`, superseding the previous `data`/`path`/`hasNext` shape. Every deferred or streamed field is announced once, in a `pending` entry, and identified afterwards by a short id instead of its path. A deferred field is announced in the same payload as its own value, while a streamed field is announced as soon as the payload exposing its containing data is sent. A `@stream` field's items are always delivered to the client in list order, buffering an item that arrives out of turn until the item before it fills the gap, and a batch of items (grouped by `preferredBatchSize` or `StreamBatching`) is delivered as the `items` of a single `incremental` entry addressed by that id, rather than one payload per item +* Fixed `GraphQLRequestHandler` answering an Apollo automatic persisted query request that carries only the hash in `extensions.persistedQuery` with the introspection result (`GET`) or a 400 "Invalid JSON body" problem (`POST`): such a request, in a JSON body, a multipart `operations` field or the `extensions` query string parameter of a `GET` request, is now answered as Apollo Server answers it with persisted queries disabled, with HTTP 200, `Cache-Control: private, no-cache, must-revalidate` and a single `PersistedQueryNotSupported` error whose `extensions.code` is `PERSISTED_QUERY_NOT_SUPPORTED`, so that Apollo Client's persisted query link falls back to sending the full query. `graphql-transport-ws` `subscribe` payloads, which must carry the query, are not checked +* **Breaking Change** Changed `GraphQLRequestHandler` to reject a request that carries both a `query` and an `extensions.persistedQuery` with the same `PersistedQueryNotSupported` error, as Apollo Server does with persisted queries disabled, instead of executing the query and ignoring the extension * Added a completion signal to the engine's deferred/streamed event stream (`DeferredCompleted`), fired once after a `@defer` field's own payload and once after all of a `@stream` field's items, whether they succeeded or the source failed; used to build the `completed` entries of the new wire format * Changed `graphql-transport-ws` delivery of a deferred field to the addressing the specification and graphql-js/Apollo clients expect: the `pending` entry names the object containing the field and the `incremental` entry carries an object map of that one field; a field whose announcement the client never received, because the payload that should have exposed it resolved to `null` there, is no longer completed * Changed the `graphql-transport-ws` middleware to single-owner loops communicating through `System.Threading.Channels`: one reader of the socket, one control loop owning the subscription registry, one sender owning every write and the close of the socket, and one worker per subscription. No lock, no thread blocked on a send, a graceful close on application shutdown; a slow client's messages now queue in memory for the life of the connection instead of blocking the subscription's source diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs index 7b00bd2f..0415f8a4 100644 --- a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs @@ -1,6 +1,8 @@ namespace FSharp.Data.GraphQL.Server.AspNetCore open System +open System.Collections.Generic +open System.Collections.Immutable open System.IO open System.Text.Json open System.Text.Json.Serialization @@ -9,6 +11,7 @@ open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging open Microsoft.Extensions.Options +open Microsoft.Extensions.Primitives open FSharp.Data.GraphQL open FsToolkit.ErrorHandling @@ -22,6 +25,106 @@ module private DeferredEventLogging = /// The path of a deferred event as one string for the log, its segments joined as GraphQL error paths print them let formatPath (path : obj list) = String.Join ("/", path) +/// +/// The members of a GraphQL over HTTP request body that does not carry. +/// +/// +/// Only the request handler reads it, alongside , so the public request type stays unchanged. +/// +[] +type internal GQLRequestEnvelope = { + /// + /// The extensions member of the request, kept as raw JSON because each extension defines its own shape. + /// + /// + /// , as the optional members of , because the configured + /// FSharp.SystemTextJson converter rejects a body that leaves out a member. + /// + Extensions : JsonElement Skippable +} + +/// +/// Recognizes requests for Apollo automatic persisted queries, which this server does not support. +/// +/// +/// +/// Apollo Client's persisted query link first sends only a hash of the query in extensions.persistedQuery, and sends +/// the full query instead once the server answers with the PersistedQueryNotSupported error. +/// +/// +/// The answer follows Apollo Server 5.5.1 with persisted queries disabled: processGraphQLRequest in +/// packages/server/src/requestPipeline.ts rejects every request whose extensions.persistedQuery is truthy, +/// whether it carries the query too or not, before looking at anything else, with the PersistedQueryNotSupportedError +/// of packages/server/src/internalErrorClasses.ts: HTTP 200 and cache-control: private, no-cache, must-revalidate. +/// +/// +/// The subscribe message of graphql-transport-ws is deliberately not checked: the protocol requires its payload +/// to carry the query, a payload without one is already rejected, and its query is always executed, never answered with the +/// introspection result. Apollo Client's GraphQLWsLink sends the full query together with the operation's extensions, +/// a persisted query hash included, and the graphql-ws reference server Apollo Server's subscription setup relies on +/// does not process persisted queries either, so rejecting such a payload would only break subscriptions that work. +/// +/// +module internal PersistedQueries = + + [] + let NotSupportedMessage = "PersistedQueryNotSupported" + + [] + let NotSupportedCode = "PERSISTED_QUERY_NOT_SUPPORTED" + + /// The member of the request extensions carrying the persisted query hash + [] + let ExtensionName = "persistedQuery" + + /// The query string parameter of a GET request carrying the request extensions as JSON + [] + let ExtensionsParameterName = "extensions" + + /// Forbids caching the answer, so that the full query the client sends next is not answered from a cache + let notSupportedCacheControl = StringValues "private, no-cache, must-revalidate" + + /// The error Apollo Server answers with. Shared by every request, which is safe because both the record and its + /// immutable extensions dictionary cannot be changed. + let notSupportedError = + let extensions = ImmutableDictionary.CreateRange (StringComparer.Ordinal, [ KeyValuePair ("code", box NotSupportedCode) ]) + GQLProblemDetails.Create (NotSupportedMessage, extensions :> IReadOnlyDictionary) + + /// Whether a JSON value is truthy in JavaScript, which is how Apollo Server tests extensions.persistedQuery + let private isTruthy (value : JsonElement) = + match value.ValueKind with + | JsonValueKind.Object + | JsonValueKind.Array + | JsonValueKind.True -> true + | JsonValueKind.String -> not (value.ValueEquals String.Empty) + | JsonValueKind.Number -> + match value.TryGetDouble () with + | true, number -> number <> 0.0 + // Out of the range of a double, so not zero + | false, _ -> true + | _ -> false + + /// Whether the extensions of a request ask for a persisted query + let isRequestedBy (extensions : JsonElement) = + extensions.ValueKind = JsonValueKind.Object + && (match extensions.TryGetProperty ExtensionName with + | true, persistedQuery -> isTruthy persistedQuery + | false, _ -> false) + + /// Whether the extensions query string parameter of a GET request asks for a persisted query. A value that is not + /// valid JSON is ignored, as the GET request handling ignores every other parameter. + let isRequestedByQueryString (extensions : StringValues) = + extensions + |> Seq.exists (fun value -> + match value with + | null -> false + | value -> + try + use document = JsonDocument.Parse value + isRequestedBy document.RootElement + with :? JsonException -> + false) + /// Handles GraphQL requests using a provided root schema. type DefaultGraphQLRequestHandler<'Root> /// @@ -155,6 +258,20 @@ and [] GraphQLRequestHandler<'Root> return bytesRead > 0 } + /// Rejects a request for an Apollo automatic persisted query with the answer Apollo Server gives when they are disabled, + /// so that Apollo Client's persisted query link sends the full query instead (see the PersistedQueries module) + let ensureNotPersistedQuery (isPersistedQuery : bool) : Result = + if not isPersistedQuery then + Ok () + else + logger.LogDebug "Request asks for an automatic persisted query, which is not supported" + ctx.Response.Headers.CacheControl <- PersistedQueries.notSupportedCacheControl + // No document was received, so there is no document id to report + let response = GQLResponse.RequestError (0, [ PersistedQueries.notSupportedError ]) + // HTTP 200 as Apollo Server answers, so that the client's handling of other statuses cannot mask the error; + // the error result stops the request from going any further + Error (TypedResults.Ok response :> IResult) + /// Execute default or custom introspection query abstract ExecuteIntrospectionQuery : ast : Ast.Document voption -> Task @@ -175,14 +292,38 @@ and [] GraphQLRequestHandler<'Root> /// and lastly by parsing document AST for introspection operation definition. /// /// + /// /// This consumes the request body: after JSON binding, the body stream position stays at its end, /// so calling this more than once (e.g. once from a derived handler and again from /// ) will fail to bind the request on the second call. + /// + /// + /// Apollo automatic persisted queries are not supported. A request whose extensions.persistedQuery is set to anything + /// but null, false, 0 or "" (the values JavaScript treats as false, as Apollo Server tests it), + /// in the JSON body or in the extensions query string parameter of a GET request, whether it carries the query + /// too or not, is answered as Apollo Server answers it with persisted queries disabled: the error result is an HTTP 200 + /// response with a single PersistedQueryNotSupported error whose extensions.code is + /// PERSISTED_QUERY_NOT_SUPPORTED, and the Cache-Control header of the response is set to + /// private, no-cache, must-revalidate. + /// /// /// Result of check of member _.CheckOperationType () = taskResult { let checkAnonymousFieldsOnly (ctx : HttpContext) = taskResult { + // Read ahead of the request itself, which cannot be bound without the query a persisted query request leaves out + let! envelope = ctx.TryBindJsonAsync(GQLRequestContent.expectedJSON) + do! + envelope.Extensions + |> Skippable.toValueOption + |> ValueOption.exists PersistedQueries.isRequestedBy + |> ensureNotPersistedQuery + + // Binding the envelope read a JSON body to its end, while a form's operations field is read again from the parsed form + let body = ctx.Request.Body + if body.CanSeek then + body.Seek (0L, SeekOrigin.Begin) |> ignore + let! gqlRequest = ctx.TryBindJsonAsync(GQLRequestContent.expectedJSON) let! ast = Parser.parseOrIResult ctx.Request.Path.Value gqlRequest.Query let operationName = gqlRequest.OperationName |> Skippable.toValueOption @@ -222,6 +363,10 @@ and [] GraphQLRequestHandler<'Root> let request = ctx.Request if HttpMethods.Get = request.Method then + do! + request.Query[PersistedQueries.ExtensionsParameterName] + |> PersistedQueries.isRequestedByQueryString + |> ensureNotPersistedQuery logger.LogTrace ("Request is GET. Must be an introspection query") return IntrospectionQuery <| ValueNone else diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs index 7a5ff11a..fdb1a255 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs @@ -2,8 +2,10 @@ module FSharp.Data.GraphQL.Tests.AspNetCore.RequestHandlerExtensibilityTests open System open System.IO +open System.Net.Http open System.Text open System.Text.Json +open System.Threading open System.Threading.Tasks open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection @@ -47,13 +49,13 @@ type private SentinelHandler let private requestBody (query : string) = JsonSerializer.Serialize {| query = query |} /// Builds a handler of the given type wired through the real `AddGraphQL` DI registration (the same path -/// a hosted app uses), backed by an `HttpContext` with the given method and JSON body. Returns it both as -/// the base type (to prove `HandleAsync` dispatches virtually) and downcast to the concrete type (to -/// assert on what it recorded), plus the DI scope to dispose once the test is done with it. -let private createHandler<'Handler when 'Handler :> GraphQLRequestHandler and 'Handler : not struct> - (method : string) - (body : string voption) - : GraphQLRequestHandler * 'Handler * IDisposable = +/// a hosted app uses), backed by an `HttpContext` whose request `configureRequest` sets up. Returns it both +/// as the base type (to prove `HandleAsync` dispatches virtually) and downcast to the concrete type (to +/// assert on what it recorded), the `HttpContext` (to execute the handler's result against), plus the DI +/// scope to dispose once the test is done with it. +let private createHandlerFor<'Handler when 'Handler :> GraphQLRequestHandler and 'Handler : not struct> + (configureRequest : HttpRequest -> unit) + : struct (GraphQLRequestHandler * 'Handler * HttpContext * IDisposable) = let services = ServiceCollection () services.AddLogging () |> ignore services.AddGraphQL(TestSchema.executor, (fun _ -> { RequestId = "test" })) @@ -62,22 +64,32 @@ let private createHandler<'Handler when 'Handler :> GraphQLRequestHandler let serviceProvider = scope.ServiceProvider let ctx = DefaultHttpContext (RequestServices = serviceProvider) - ctx.Request.Method <- method ctx.Request.Path <- PathString "/graphql" - - body - |> ValueOption.iter (fun json -> - ctx.Request.ContentType <- "application/json" - let bytes = Encoding.UTF8.GetBytes (json : string) - ctx.Request.Body <- new MemoryStream (bytes) - ctx.Request.ContentLength <- int64 bytes.Length) + configureRequest ctx.Request // The handler captures `httpContextAccessor.HttpContext` in its own constructor, so the accessor // must carry the request's HttpContext before the handler is resolved from the container. serviceProvider.GetRequiredService().HttpContext <- ctx let handler = serviceProvider.GetRequiredService>() - handler, (handler :?> 'Handler), (scope :> IDisposable) + struct (handler, (handler :?> 'Handler), (ctx :> HttpContext), (scope :> IDisposable)) + +let private setJsonBody (json : string) (request : HttpRequest) = + request.ContentType <- "application/json" + let bytes = Encoding.UTF8.GetBytes json + request.Body <- new MemoryStream (bytes) + request.ContentLength <- int64 bytes.Length + +/// Builds a handler as `createHandlerFor` does, backed by an `HttpContext` with the given method and JSON body. +let private createHandler<'Handler when 'Handler :> GraphQLRequestHandler and 'Handler : not struct> + (method : string) + (body : string voption) + : GraphQLRequestHandler * 'Handler * IDisposable = + let struct (handler, concreteHandler, _, scope) = + createHandlerFor<'Handler> (fun request -> + request.Method <- method + body |> ValueOption.iter (fun json -> setJsonBody json request)) + handler, concreteHandler, scope let private assertOkResponse (outcome : Result) = match outcome with @@ -142,3 +154,228 @@ let ``Overridden HandleAsync is used when the handler is resolved as GraphQLRequ Assert.Equal ("sentinel", ok.Value) | Error iresult -> fail $"Expected HandleAsync to succeed, but it returned an error result: %A{iresult}" } + +// Automatic persisted queries (APQ): Apollo Client's persisted query link first sends only a hash of the query in +// `extensions.persistedQuery` and falls back to the full query once the server answers `PersistedQueryNotSupported`. + +let private persistedQueryName = "HeroName" +let private persistedQueryText = "query HeroName { hero(id: \"1000\") { name } }" + +/// The extension Apollo Client's persisted query link sends: the SHA-256 of `persistedQueryText` +let private persistedQueryExtensions = + """{"persistedQuery":{"version":1,"sha256Hash":"1cbc2ac2578b38723ede16940d5c898aac7cad1fd978cba363154afb07d130fc"}}""" + +/// Sets the request up as a multipart request, the way file uploads are sent, carrying the given `operations` JSON +let private setMultipartOperations (operations : string) (request : HttpRequest) = + use content = new MultipartFormDataContent () + content.Add (new StringContent (operations), "operations") + content.Add (new StringContent ("{}"), "map") + let body = new MemoryStream () + content.CopyTo (body, null, CancellationToken.None) + body.Position <- 0L + request.ContentType <- string content.Headers.ContentType + request.Body <- body + request.ContentLength <- body.Length + +let private setQueryString (parameters : (string * string) list) (request : HttpRequest) = + request.QueryString <- + parameters + |> List.fold (fun (queryString : QueryString) (name, value) -> queryString.Add (name, value)) QueryString.Empty + +/// Executes the handler's result against its `HttpContext`, as the Giraffe and Oxpecker endpoints do, and returns the +/// status code and the body written to the response. +let private executeOutcome (ctx : HttpContext) (outcome : Result) : Task = task { + let result = + match outcome with + | Ok result + | Error result -> result + use body = new MemoryStream () + ctx.Response.Body <- body + do! result.ExecuteAsync ctx + return struct (ctx.Response.StatusCode, Encoding.UTF8.GetString (body.ToArray ())) +} + +let private wantMember (name : string) (responseBody : string) (element : JsonElement) = + match element.TryGetProperty name with + | true, value -> value + | false, _ -> + fail $"Expected a '%s{name}' member in %A{element.ValueKind} %s{element.GetRawText ()} of the response:\n%s{responseBody}" + Unchecked.defaultof<_> + +/// Asserts that the request was answered the way Apollo Server answers a persisted query when they are disabled: an HTTP 200 +/// response, uncacheable, with a single `PersistedQueryNotSupported` error and no data, and that nothing was executed. +let private assertPersistedQueryNotSupported (recorder : RecordingHandler) (ctx : HttpContext) (outcome : Result) = task { + Assert.True ( + recorder.IntrospectionCalls.Count = 0, + $"Expected a persisted query request not to be answered with the introspection result, but ExecuteIntrospectionQuery was called %d{recorder.IntrospectionCalls.Count} time(s)" + ) + Assert.True ( + recorder.OperationCalls.Count = 0, + $"Expected a persisted query request not to be executed, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s)" + ) + + let! struct (statusCode, responseBody) = executeOutcome ctx outcome + + Assert.True ( + (statusCode = StatusCodes.Status200OK), + $"Expected HTTP 200, as Apollo Server answers an unsupported persisted query, but got HTTP %d{statusCode} with body:\n%s{responseBody}" + ) + use document = JsonDocument.Parse responseBody + let root = document.RootElement + Assert.False (fst (root.TryGetProperty "data"), $"Expected no 'data' member in the response of a rejected request, but got:\n%s{responseBody}") + let errors = root |> wantMember "errors" responseBody + let error = + match errors.EnumerateArray () |> Seq.toList with + | [ error ] -> error + | _ -> + fail $"Expected exactly one error in the response, but got:\n%s{responseBody}" + Unchecked.defaultof<_> + Assert.Equal ("PersistedQueryNotSupported", (error |> wantMember "message" responseBody).GetString ()) + let code = error |> wantMember "extensions" responseBody |> wantMember "code" responseBody + Assert.Equal ("PERSISTED_QUERY_NOT_SUPPORTED", code.GetString ()) + Assert.Equal ("private, no-cache, must-revalidate", ctx.Response.Headers.CacheControl.ToString ()) +} + +[] +let ``POST with only a persisted query hash is answered with PersistedQueryNotSupported`` () : Task = task { + let body = $"""{{"operationName":"%s{persistedQueryName}","variables":{{}},"extensions":%s{persistedQueryExtensions}}}""" + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Post + setJsonBody body request) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome +} + +[] +let ``GET with only a persisted query hash is answered with PersistedQueryNotSupported instead of the introspection result`` () : Task = task { + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Get + request + |> setQueryString [ + "operationName", persistedQueryName + "variables", "{}" + "extensions", persistedQueryExtensions + ]) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome +} + +[] +let ``POST with both a query and a persisted query hash is answered with PersistedQueryNotSupported as Apollo Server does`` () : Task = task { + let body = + $"""{{"query":%s{JsonSerializer.Serialize persistedQueryText},"operationName":"%s{persistedQueryName}","extensions":%s{persistedQueryExtensions}}}""" + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Post + setJsonBody body request) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome +} + +[] +let ``Multipart POST with a persisted query hash in its operations is answered with PersistedQueryNotSupported`` () : Task = task { + let operations = $"""{{"operationName":"%s{persistedQueryName}","extensions":%s{persistedQueryExtensions}}}""" + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Post + setMultipartOperations operations request) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome +} + +[] +let ``Multipart POST operation is executed as before`` () : Task = task { + let query = "query { hero(id: \"1000\") { id name } }" + let struct (handler, recorder, _, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Post + setMultipartOperations (requestBody query) request) + use _ = scope + + let! outcome = handler.HandleAsync () + + Assert.True ( + (recorder.OperationCalls.Count = 1), + $"Expected the operation of a multipart request to be executed once, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s); outcome: %A{outcome}" + ) + Assert.Equal (query, recorder.OperationCalls[0].Query) + Assert.Equal (0, recorder.IntrospectionCalls.Count) + assertOkResponse outcome +} + +[] +let ``GET with both a query and a persisted query hash is answered with PersistedQueryNotSupported as Apollo Server does`` () : Task = task { + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Get + request + |> setQueryString [ + "query", persistedQueryText + "operationName", persistedQueryName + "extensions", persistedQueryExtensions + ]) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome +} + +// Apollo Server tests `extensions.persistedQuery` for JavaScript truthiness, so null, false, "" and 0 do not ask for one +[] +[] +[] +[] +[] +[] +[] +[] +let ``POST operation whose extensions do not ask for a persisted query is executed as before`` (extensions : string) : Task = task { + let query = "query { hero(id: \"1000\") { id name } }" + let body = $"""{{"query":%s{JsonSerializer.Serialize query},"extensions":%s{extensions}}}""" + let handler, recorder, scope = createHandler HttpMethods.Post (ValueSome body) + use _ = scope + + let! outcome = handler.HandleAsync () + + Assert.True ( + (recorder.OperationCalls.Count = 1), + $"Expected the operation to be executed once despite extensions %s{extensions}, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s)" + ) + Assert.Equal (query, recorder.OperationCalls[0].Query) + Assert.Equal (0, recorder.IntrospectionCalls.Count) + assertOkResponse outcome +} + +[] +let ``GET whose extensions do not ask for a persisted query is still answered with the introspection result`` () : Task = task { + let struct (handler, recorder, _, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Get + request |> setQueryString [ "extensions", """{"tracing":true}""" ]) + use _ = scope + + let! outcome = handler.HandleAsync () + + Assert.True ( + (recorder.IntrospectionCalls.Count = 1), + $"Expected the introspection query to be executed once, but ExecuteIntrospectionQuery was called %d{recorder.IntrospectionCalls.Count} time(s)" + ) + Assert.Equal (ValueNone, recorder.IntrospectionCalls[0]) + Assert.Equal (0, recorder.OperationCalls.Count) + assertOkResponse outcome +} From 1b08823c3dcca34275fe26d66dce9020b6f343f9 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 05:47:44 +0200 Subject: [PATCH 2/3] Fixed the persisted query answer stopping Apollo Client 4 from falling back - The answer to a persisted query request is now {"errors": [...]} alone, as Apollo Server writes it: GQLResponse.RequestError added documentId, and Apollo Client 4 takes a body with a top-level member other than data, errors and extensions for no GraphQL result, so its persisted query link neither fell back to the full query nor disabled persisted queries. - The extensions of a request are no longer bound as a JsonElement, which copied them: a client could make every request allocate a copy of a body as large as the server accepts (20 898 KB per request for a 20 MB body, now 416 KB, as for any unknown member). PersistedQueryRequest and its converter evaluate the truthiness of persistedQuery from the reader and skip every other value; the GET extensions parameter goes through the same converter. - Wrapped the doc comments with tags in . - Tests now check that the answer has no other member than errors, the truthiness of more values, duplicate and nested members, and GET extensions that do not ask for a persisted query. Co-Authored-By: Claude Opus 5.5 --- RELEASE_NOTES.md | 2 +- .../GraphQLRequestHandler.fs | 137 ++++++++++++------ .../RequestHandlerExtensibilityTests.fs | 66 ++++++--- 3 files changed, 142 insertions(+), 63 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1172cb7b..e1b1372b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -310,7 +310,7 @@ * Added `GraphQLTransportWS.SubProtocol`, the `graphql-transport-ws` sub-protocol name * Fixed `graphql-transport-ws` delivery of `@defer` and `@stream` results, which are now sent as soon as they are produced instead of after a fixed 5 second delay, followed by a final payload with `hasNext: false` * Changed `graphql-transport-ws` incremental delivery of `@defer` and `@stream` results to the `pending`/`incremental`/`completed`/`hasNext` wire format used by graphql-js 17 and Apollo Client's `GraphQL17Alpha9Handler`, superseding the previous `data`/`path`/`hasNext` shape. Every deferred or streamed field is announced once, in a `pending` entry, and identified afterwards by a short id instead of its path. A deferred field is announced in the same payload as its own value, while a streamed field is announced as soon as the payload exposing its containing data is sent. A `@stream` field's items are always delivered to the client in list order, buffering an item that arrives out of turn until the item before it fills the gap, and a batch of items (grouped by `preferredBatchSize` or `StreamBatching`) is delivered as the `items` of a single `incremental` entry addressed by that id, rather than one payload per item -* Fixed `GraphQLRequestHandler` answering an Apollo automatic persisted query request that carries only the hash in `extensions.persistedQuery` with the introspection result (`GET`) or a 400 "Invalid JSON body" problem (`POST`): such a request, in a JSON body, a multipart `operations` field or the `extensions` query string parameter of a `GET` request, is now answered as Apollo Server answers it with persisted queries disabled, with HTTP 200, `Cache-Control: private, no-cache, must-revalidate` and a single `PersistedQueryNotSupported` error whose `extensions.code` is `PERSISTED_QUERY_NOT_SUPPORTED`, so that Apollo Client's persisted query link falls back to sending the full query. `graphql-transport-ws` `subscribe` payloads, which must carry the query, are not checked +* Fixed `GraphQLRequestHandler` answering an Apollo automatic persisted query request that carries only the hash in `extensions.persistedQuery` with the introspection result (`GET`) or a 400 "Invalid JSON body" problem (`POST`): such a request, in a JSON body, a multipart `operations` field or the `extensions` query string parameter of a `GET` request, is now answered as Apollo Server answers it with persisted queries disabled, with HTTP 200, `Cache-Control: private, no-cache, must-revalidate` and a body holding only a single `PersistedQueryNotSupported` error whose `extensions.code` is `PERSISTED_QUERY_NOT_SUPPORTED`, so that Apollo Client's persisted query link falls back to sending the full query; Apollo Client 4 would not fall back from a body with any other top-level member, such as `documentId`. `graphql-transport-ws` `subscribe` payloads, which must carry the query, are not checked * **Breaking Change** Changed `GraphQLRequestHandler` to reject a request that carries both a `query` and an `extensions.persistedQuery` with the same `PersistedQueryNotSupported` error, as Apollo Server does with persisted queries disabled, instead of executing the query and ignoring the extension * Added a completion signal to the engine's deferred/streamed event stream (`DeferredCompleted`), fired once after a `@defer` field's own payload and once after all of a `@stream` field's items, whether they succeeded or the source failed; used to build the `completed` entries of the new wire format * Changed `graphql-transport-ws` delivery of a deferred field to the addressing the specification and graphql-js/Apollo clients expect: the `pending` entry names the object containing the field and the `incremental` entry carries an object map of that one field; a field whose announcement the client never received, because the payload that should have exposed it resolved to `null` there, is no longer completed diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs index 0415f8a4..f5dfc335 100644 --- a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs @@ -25,24 +25,6 @@ module private DeferredEventLogging = /// The path of a deferred event as one string for the log, its segments joined as GraphQL error paths print them let formatPath (path : obj list) = String.Join ("/", path) -/// -/// The members of a GraphQL over HTTP request body that does not carry. -/// -/// -/// Only the request handler reads it, alongside , so the public request type stays unchanged. -/// -[] -type internal GQLRequestEnvelope = { - /// - /// The extensions member of the request, kept as raw JSON because each extension defines its own shape. - /// - /// - /// , as the optional members of , because the configured - /// FSharp.SystemTextJson converter rejects a body that leaves out a member. - /// - Extensions : JsonElement Skippable -} - /// /// Recognizes requests for Apollo automatic persisted queries, which this server does not support. /// @@ -90,41 +72,107 @@ module internal PersistedQueries = let extensions = ImmutableDictionary.CreateRange (StringComparer.Ordinal, [ KeyValuePair ("code", box NotSupportedCode) ]) GQLProblemDetails.Create (NotSupportedMessage, extensions :> IReadOnlyDictionary) - /// Whether a JSON value is truthy in JavaScript, which is how Apollo Server tests extensions.persistedQuery - let private isTruthy (value : JsonElement) = - match value.ValueKind with - | JsonValueKind.Object - | JsonValueKind.Array - | JsonValueKind.True -> true - | JsonValueKind.String -> not (value.ValueEquals String.Empty) - | JsonValueKind.Number -> - match value.TryGetDouble () with - | true, number -> number <> 0.0 - // Out of the range of a double, so not zero - | false, _ -> true - | _ -> false +/// +/// Whether the extensions of a request ask for an Apollo automatic persisted query: whether their +/// persistedQuery member is truthy in JavaScript, which is how Apollo Server tests it. +/// +/// +/// +/// reads it straight from the JSON of the request and skips every other value, +/// so that extensions of any size are never copied: a client could otherwise make every request allocate a copy of a +/// body as large as the server accepts. +/// +/// +/// It is a struct rather than a record: the converters of the serializer options take precedence over the converter of a +/// type, and the F# converter among them would read a record as one. +/// +/// +[)>] +type internal PersistedQueryRequest + /// Whether the extensions ask for a persisted query. + (isRequested : bool) = - /// Whether the extensions of a request ask for a persisted query - let isRequestedBy (extensions : JsonElement) = - extensions.ValueKind = JsonValueKind.Object - && (match extensions.TryGetProperty ExtensionName with - | true, persistedQuery -> isTruthy persistedQuery - | false, _ -> false) + /// Whether the extensions ask for a persisted query + member _.IsRequested = isRequested + /// /// Whether the extensions query string parameter of a GET request asks for a persisted query. A value that is not /// valid JSON is ignored, as the GET request handling ignores every other parameter. - let isRequestedByQueryString (extensions : StringValues) = + /// + static member IsRequestedByQueryString (extensions : StringValues) = extensions |> Seq.exists (fun value -> match value with | null -> false | value -> try - use document = JsonDocument.Parse value - isRequestedBy document.RootElement + (JsonSerializer.Deserialize value).IsRequested with :? JsonException -> false) +/// Reads a from the extensions of a request. +and [] internal PersistedQueryRequestConverter () = + inherit JsonConverter () + + /// + /// Whether the JSON value the reader is at is truthy in JavaScript, leaving the reader at the end of the value + /// + static member private IsTruthy (reader : byref) = + match reader.TokenType with + | JsonTokenType.StartObject + | JsonTokenType.StartArray -> + reader.Skip () + true + | JsonTokenType.True -> true + // The raw text of a string is empty only when the string is + | JsonTokenType.String -> + if reader.HasValueSequence then + reader.ValueSequence.Length > 0L + else + reader.ValueSpan.Length > 0 + | JsonTokenType.Number -> + match reader.TryGetDouble () with + | true, number -> number <> 0.0 + // Out of the range of a double, so not zero + | false, _ -> true + | _ -> false + + override _.Read (reader, _, _) = + let mutable isRequested = false + if reader.TokenType = JsonTokenType.StartObject then + // Each property name is followed by its value; with duplicate members the last one counts, as in JSON.parse + while reader.Read () && reader.TokenType <> JsonTokenType.EndObject do + let isPersistedQuery = reader.ValueTextEquals PersistedQueries.ExtensionName + reader.Read () |> ignore + if isPersistedQuery then + isRequested <- PersistedQueryRequestConverter.IsTruthy (&reader) + else + reader.Skip () + else + // Extensions that are not an object ask for nothing, as they hold no persistedQuery member + reader.Skip () + PersistedQueryRequest isRequested + + override _.Write (_, _, _) = raise (NotSupportedException "Persisted query requests are only read.") + +/// +/// The members of a GraphQL over HTTP request body that does not carry. +/// +/// +/// Only the request handler reads it, alongside , so the public request type stays unchanged. +/// +[] +type internal GQLRequestEnvelope = { + /// + /// Whether the extensions member of the request asks for a persisted query, the only extension the handler reads. + /// + /// + /// , as the optional members of , because the configured + /// FSharp.SystemTextJson converter rejects a body that leaves out a value option member. + /// + Extensions : PersistedQueryRequest Skippable +} + /// Handles GraphQL requests using a provided root schema. type DefaultGraphQLRequestHandler<'Root> /// @@ -266,8 +314,9 @@ and [] GraphQLRequestHandler<'Root> else logger.LogDebug "Request asks for an automatic persisted query, which is not supported" ctx.Response.Headers.CacheControl <- PersistedQueries.notSupportedCacheControl - // No document was received, so there is no document id to report - let response = GQLResponse.RequestError (0, [ PersistedQueries.notSupportedError ]) + // Only the errors, as Apollo Server answers: Apollo Client 4 takes a result with any other top-level member than + // data, errors and extensions for no GraphQL result at all, and then never falls back to the full query + let response = {| errors = [ PersistedQueries.notSupportedError ] |} // HTTP 200 as Apollo Server answers, so that the client's handling of other statuses cannot mask the error; // the error result stops the request from going any further Error (TypedResults.Ok response :> IResult) @@ -316,7 +365,7 @@ and [] GraphQLRequestHandler<'Root> do! envelope.Extensions |> Skippable.toValueOption - |> ValueOption.exists PersistedQueries.isRequestedBy + |> ValueOption.exists _.IsRequested |> ensureNotPersistedQuery // Binding the envelope read a JSON body to its end, while a form's operations field is read again from the parsed form @@ -365,7 +414,7 @@ and [] GraphQLRequestHandler<'Root> if HttpMethods.Get = request.Method then do! request.Query[PersistedQueries.ExtensionsParameterName] - |> PersistedQueries.isRequestedByQueryString + |> PersistedQueryRequest.IsRequestedByQueryString |> ensureNotPersistedQuery logger.LogTrace ("Request is GET. Must be an introspection query") return IntrospectionQuery <| ValueNone diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs index fdb1a255..65605d28 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs @@ -205,14 +205,9 @@ let private wantMember (name : string) (responseBody : string) (element : JsonEl /// Asserts that the request was answered the way Apollo Server answers a persisted query when they are disabled: an HTTP 200 /// response, uncacheable, with a single `PersistedQueryNotSupported` error and no data, and that nothing was executed. let private assertPersistedQueryNotSupported (recorder : RecordingHandler) (ctx : HttpContext) (outcome : Result) = task { - Assert.True ( - recorder.IntrospectionCalls.Count = 0, - $"Expected a persisted query request not to be answered with the introspection result, but ExecuteIntrospectionQuery was called %d{recorder.IntrospectionCalls.Count} time(s)" - ) - Assert.True ( - recorder.OperationCalls.Count = 0, - $"Expected a persisted query request not to be executed, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s)" - ) + // A persisted query request is neither answered with the introspection result nor executed + Assert.Empty recorder.IntrospectionCalls + Assert.Empty recorder.OperationCalls let! struct (statusCode, responseBody) = executeOutcome ctx outcome @@ -222,14 +217,12 @@ let private assertPersistedQueryNotSupported (recorder : RecordingHandler) (ctx ) use document = JsonDocument.Parse responseBody let root = document.RootElement - Assert.False (fst (root.TryGetProperty "data"), $"Expected no 'data' member in the response of a rejected request, but got:\n%s{responseBody}") + // Apollo Client 4 falls back to the full query only for a result without other top-level members than data, errors and + // extensions, so the answer has the errors alone, as Apollo Server's + let members = root.EnumerateObject () |> Seq.map _.Name |> List.ofSeq + Assert.True ((members = [ "errors" ]), $"Expected the response to have the member 'errors' alone, but got:\n%s{responseBody}") let errors = root |> wantMember "errors" responseBody - let error = - match errors.EnumerateArray () |> Seq.toList with - | [ error ] -> error - | _ -> - fail $"Expected exactly one error in the response, but got:\n%s{responseBody}" - Unchecked.defaultof<_> + let error = Assert.Single (errors.EnumerateArray ()) Assert.Equal ("PersistedQueryNotSupported", (error |> wantMember "message" responseBody).GetString ()) let code = error |> wantMember "extensions" responseBody |> wantMember "code" responseBody Assert.Equal ("PERSISTED_QUERY_NOT_SUPPORTED", code.GetString ()) @@ -343,6 +336,10 @@ let ``GET with both a query and a persisted query hash is answered with Persiste [] [] [] +[] +[] +[] +[] [] let ``POST operation whose extensions do not ask for a persisted query is executed as before`` (extensions : string) : Task = task { let query = "query { hero(id: \"1000\") { id name } }" @@ -361,12 +358,45 @@ let ``POST operation whose extensions do not ask for a persisted query is execut assertOkResponse outcome } -[] -let ``GET whose extensions do not ask for a persisted query is still answered with the introspection result`` () : Task = task { +// The last of duplicate members counts, as in JSON.parse +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``POST whose extensions ask for a persisted query in any JavaScript truthy way is answered with PersistedQueryNotSupported`` + (extensions : string) + : Task = + task { + let body = $"""{{"query":%s{JsonSerializer.Serialize persistedQueryText},"extensions":%s{extensions}}}""" + let struct (handler, recorder, ctx, scope) = + createHandlerFor (fun request -> + request.Method <- HttpMethods.Post + setJsonBody body request) + use _ = scope + + let! outcome = handler.HandleAsync () + + do! assertPersistedQueryNotSupported recorder ctx outcome + } + +// A value that is not JSON is ignored, as the GET request handling ignores every other parameter +[] +[] +[] +[] +[] +[] +[] +let ``GET whose extensions do not ask for a persisted query is still answered with the introspection result`` (extensions : string) : Task = task { let struct (handler, recorder, _, scope) = createHandlerFor (fun request -> request.Method <- HttpMethods.Get - request |> setQueryString [ "extensions", """{"tracing":true}""" ]) + request |> setQueryString [ "extensions", extensions ]) use _ = scope let! outcome = handler.HandleAsync () From e5c058e3b02bef59a58561230c4564d58e343dd5 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 12:01:03 +0200 Subject: [PATCH 3/3] Named the persisted query constants and the media type in the tests The tests take the error message, its code, the cache directive and the query string parameter from `PersistedQueries` and the media type from `MediaTypeNames` instead of spelling them out again, and assert with `Assert.Equal` and `Assert.Single` instead of `Assert.True` on a comparison. The error extensions are built with `kvpObj` and without an upcast. Co-Authored-By: Claude Opus 5.5 --- .../GraphQLRequestHandler.fs | 4 +- .../RequestHandlerExtensibilityTests.fs | 44 +++++++------------ 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs index f5dfc335..c5a8c199 100644 --- a/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs +++ b/src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs @@ -69,8 +69,8 @@ module internal PersistedQueries = /// The error Apollo Server answers with. Shared by every request, which is safe because both the record and its /// immutable extensions dictionary cannot be changed. let notSupportedError = - let extensions = ImmutableDictionary.CreateRange (StringComparer.Ordinal, [ KeyValuePair ("code", box NotSupportedCode) ]) - GQLProblemDetails.Create (NotSupportedMessage, extensions :> IReadOnlyDictionary) + let extensions = ImmutableDictionary.CreateRange (StringComparer.Ordinal, [ kvpObj "code" NotSupportedCode ]) + GQLProblemDetails.Create (NotSupportedMessage, extensions) /// /// Whether the extensions of a request ask for an Apollo automatic persisted query: whether their diff --git a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs index 65605d28..c60cdb7d 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestHandlerExtensibilityTests.fs @@ -3,6 +3,7 @@ module FSharp.Data.GraphQL.Tests.AspNetCore.RequestHandlerExtensibilityTests open System open System.IO open System.Net.Http +open System.Net.Mime open System.Text open System.Text.Json open System.Threading @@ -75,7 +76,7 @@ let private createHandlerFor<'Handler when 'Handler :> GraphQLRequestHandler 'Handler), (ctx :> HttpContext), (scope :> IDisposable)) let private setJsonBody (json : string) (request : HttpRequest) = - request.ContentType <- "application/json" + request.ContentType <- MediaTypeNames.Application.Json let bytes = Encoding.UTF8.GetBytes json request.Body <- new MemoryStream (bytes) request.ContentLength <- int64 bytes.Length @@ -211,22 +212,20 @@ let private assertPersistedQueryNotSupported (recorder : RecordingHandler) (ctx let! struct (statusCode, responseBody) = executeOutcome ctx outcome - Assert.True ( - (statusCode = StatusCodes.Status200OK), - $"Expected HTTP 200, as Apollo Server answers an unsupported persisted query, but got HTTP %d{statusCode} with body:\n%s{responseBody}" - ) + // HTTP 200, as Apollo Server answers an unsupported persisted query + Assert.Equal (StatusCodes.Status200OK, statusCode) use document = JsonDocument.Parse responseBody let root = document.RootElement // Apollo Client 4 falls back to the full query only for a result without other top-level members than data, errors and // extensions, so the answer has the errors alone, as Apollo Server's let members = root.EnumerateObject () |> Seq.map _.Name |> List.ofSeq - Assert.True ((members = [ "errors" ]), $"Expected the response to have the member 'errors' alone, but got:\n%s{responseBody}") + Assert.Equal ([ "errors" ], members) let errors = root |> wantMember "errors" responseBody let error = Assert.Single (errors.EnumerateArray ()) - Assert.Equal ("PersistedQueryNotSupported", (error |> wantMember "message" responseBody).GetString ()) + Assert.Equal (PersistedQueries.NotSupportedMessage, (error |> wantMember "message" responseBody).GetString ()) let code = error |> wantMember "extensions" responseBody |> wantMember "code" responseBody - Assert.Equal ("PERSISTED_QUERY_NOT_SUPPORTED", code.GetString ()) - Assert.Equal ("private, no-cache, must-revalidate", ctx.Response.Headers.CacheControl.ToString ()) + Assert.Equal (PersistedQueries.NotSupportedCode, code.GetString ()) + Assert.Equal (PersistedQueries.notSupportedCacheControl.ToString (), ctx.Response.Headers.CacheControl.ToString ()) } [] @@ -252,7 +251,7 @@ let ``GET with only a persisted query hash is answered with PersistedQueryNotSup |> setQueryString [ "operationName", persistedQueryName "variables", "{}" - "extensions", persistedQueryExtensions + PersistedQueries.ExtensionsParameterName, persistedQueryExtensions ]) use _ = scope @@ -301,11 +300,8 @@ let ``Multipart POST operation is executed as before`` () : Task = task { let! outcome = handler.HandleAsync () - Assert.True ( - (recorder.OperationCalls.Count = 1), - $"Expected the operation of a multipart request to be executed once, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s); outcome: %A{outcome}" - ) - Assert.Equal (query, recorder.OperationCalls[0].Query) + let call = Assert.Single recorder.OperationCalls + Assert.Equal (query, call.Query) Assert.Equal (0, recorder.IntrospectionCalls.Count) assertOkResponse outcome } @@ -319,7 +315,7 @@ let ``GET with both a query and a persisted query hash is answered with Persiste |> setQueryString [ "query", persistedQueryText "operationName", persistedQueryName - "extensions", persistedQueryExtensions + PersistedQueries.ExtensionsParameterName, persistedQueryExtensions ]) use _ = scope @@ -349,11 +345,8 @@ let ``POST operation whose extensions do not ask for a persisted query is execut let! outcome = handler.HandleAsync () - Assert.True ( - (recorder.OperationCalls.Count = 1), - $"Expected the operation to be executed once despite extensions %s{extensions}, but ExecuteOperation was called %d{recorder.OperationCalls.Count} time(s)" - ) - Assert.Equal (query, recorder.OperationCalls[0].Query) + let call = Assert.Single recorder.OperationCalls + Assert.Equal (query, call.Query) Assert.Equal (0, recorder.IntrospectionCalls.Count) assertOkResponse outcome } @@ -396,16 +389,13 @@ let ``GET whose extensions do not ask for a persisted query is still answered wi let struct (handler, recorder, _, scope) = createHandlerFor (fun request -> request.Method <- HttpMethods.Get - request |> setQueryString [ "extensions", extensions ]) + request |> setQueryString [ PersistedQueries.ExtensionsParameterName, extensions ]) use _ = scope let! outcome = handler.HandleAsync () - Assert.True ( - (recorder.IntrospectionCalls.Count = 1), - $"Expected the introspection query to be executed once, but ExecuteIntrospectionQuery was called %d{recorder.IntrospectionCalls.Count} time(s)" - ) - Assert.Equal (ValueNone, recorder.IntrospectionCalls[0]) + let call = Assert.Single recorder.IntrospectionCalls + Assert.Equal (ValueNone, call) Assert.Equal (0, recorder.OperationCalls.Count) assertOkResponse outcome }