Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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 <noreply@anthropic.com>
  • Loading branch information
xperiandri and claude committed Oct 3, 2026
commit cf4402263ad093ff0320800b7882b87b4307da61
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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<obj> ("/", path)

/// <summary>
/// The members of a GraphQL over HTTP request body that <see cref="GQLRequestContent"/> does not carry.
/// </summary>
/// <remarks>
/// Only the request handler reads it, alongside <see cref="GQLRequestContent"/>, so the public request type stays unchanged.
/// </remarks>
[<Struct>]
type internal GQLRequestEnvelope = {
/// <summary>
/// The <c>extensions</c> member of the request, kept as raw JSON because each extension defines its own shape.
/// </summary>
/// <remarks>
/// <see cref="Skippable{T}"/>, as the optional members of <see cref="GQLRequestContent"/>, because the configured
/// FSharp.SystemTextJson converter rejects a body that leaves out a <see langword="voption"/> member.
/// </remarks>
Extensions : JsonElement Skippable
}

/// <summary>
/// Recognizes requests for Apollo automatic persisted queries, which this server does not support.
/// </summary>
/// <remarks>
/// <para>
/// Apollo Client's persisted query link first sends only a hash of the query in <c>extensions.persistedQuery</c>, and sends
/// the full query instead once the server answers with the <c>PersistedQueryNotSupported</c> error.
/// </para>
/// <para>
/// The answer follows Apollo Server 5.5.1 with persisted queries disabled: <c>processGraphQLRequest</c> in
/// <c>packages/server/src/requestPipeline.ts</c> rejects every request whose <c>extensions.persistedQuery</c> is truthy,
/// whether it carries the query too or not, before looking at anything else, with the <c>PersistedQueryNotSupportedError</c>
/// of <c>packages/server/src/internalErrorClasses.ts</c>: HTTP 200 and <c>cache-control: private, no-cache, must-revalidate</c>.
/// </para>
/// <para>
/// The <c>subscribe</c> message of <c>graphql-transport-ws</c> 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 <c>GraphQLWsLink</c> sends the full query together with the operation's extensions,
/// a persisted query hash included, and the <c>graphql-ws</c> 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.
/// </para>
/// </remarks>
module internal PersistedQueries =

[<Literal>]
let NotSupportedMessage = "PersistedQueryNotSupported"

[<Literal>]
let NotSupportedCode = "PERSISTED_QUERY_NOT_SUPPORTED"

/// The member of the request extensions carrying the persisted query hash
[<Literal>]
let ExtensionName = "persistedQuery"

/// The query string parameter of a GET request carrying the request extensions as JSON
[<Literal>]
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<string, obj>)

/// Whether a JSON value is truthy in JavaScript, which is how Apollo Server tests <c>extensions.persistedQuery</c>
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 <c>extensions</c> 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 <c>extensions</c> 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>
/// <summary>
Expand Down Expand Up @@ -155,6 +258,20 @@ and [<AbstractClass>] 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<unit, IResult> =
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<IResult>

Expand All @@ -175,14 +292,38 @@ and [<AbstractClass>] GraphQLRequestHandler<'Root>
/// and lastly by parsing document AST for introspection operation definition.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="HandleAsync"/>) will fail to bind the request on the second call.
/// </para>
/// <para>
/// Apollo automatic persisted queries are not supported. A request whose <c>extensions.persistedQuery</c> is set to anything
/// but <c>null</c>, <c>false</c>, <c>0</c> or <c>""</c> (the values JavaScript treats as false, as Apollo Server tests it),
/// in the JSON body or in the <c>extensions</c> query string parameter of a <c>GET</c> 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 <c>PersistedQueryNotSupported</c> error whose <c>extensions.code</c> is
/// <c>PERSISTED_QUERY_NOT_SUPPORTED</c>, and the <c>Cache-Control</c> header of the response is set to
/// <c>private, no-cache, must-revalidate</c>.
/// </para>
/// </remarks>
/// <returns>Result of check of <see cref="OperationType"/></returns>
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<GQLRequestEnvelope>(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>(GQLRequestContent.expectedJSON)
let! ast = Parser.parseOrIResult ctx.Request.Path.Value gqlRequest.Query
let operationName = gqlRequest.OperationName |> Skippable.toValueOption
Expand Down Expand Up @@ -222,6 +363,10 @@ and [<AbstractClass>] 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
Expand Down
Loading