Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ public static IApplicationBuilder MapGraphQL(
.Use(MiddlewareFactory.CreateWebSocketSubscriptionMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions))
.Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpGetMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpGetSchemaMiddleware(
executor, serverOptions, path, MiddlewareRoutingType.Integrated))
Expand Down Expand Up @@ -217,6 +218,7 @@ public static GraphQLHttpEndpointConventionBuilder MapGraphQLHttp(
.Use(MiddlewareFactory.CreateCancellationMiddleware())
.Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions))
.Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpGetMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpUnsupportedRequestMiddleware(
executor,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#if !NET10_0_OR_GREATER
using Microsoft.AspNetCore.Http;

namespace HotChocolate.AspNetCore;

/// <summary>
/// Supplies the QUERY members of <see cref="HttpMethods"/> on target frameworks whose
/// ASP.NET Core does not define them.
/// </summary>
internal static class HttpMethodsExtensions
{
extension(HttpMethods)
{
public static string Query => "QUERY";

public static bool IsQuery(string method)
=> HttpMethods.Equals(HttpMethods.Query, method);
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ namespace HotChocolate.AspNetCore;

internal static class HttpHeaderKeys
{
public const string AcceptQuery = "Accept-Query";

public const string OperationPlan = "Fusion-Operation-Plan";

public const string CacheControl = "Cache-Control";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ namespace HotChocolate.AspNetCore;

internal static class HttpHeaderValues
{
public const string AcceptQueryMediaTypes = "application/json";

public const string IncludeOperationPlan = "1";

public const string NoCache = "no-cache";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Net;
using HotChocolate.AspNetCore.Instrumentation;
using HotChocolate.AspNetCore.Utilities;
using Microsoft.AspNetCore.Http;
using HttpRequestDelegate = Microsoft.AspNetCore.Http.RequestDelegate;

namespace HotChocolate.AspNetCore;

/// <summary>
/// Handles GraphQL requests sent with the HTTP QUERY method. The request body has the shape of
/// a POST body and carries exactly one query operation.
/// </summary>
public sealed class HttpQueryMiddleware : MiddlewareBase
{
private const string BatchOperations = "batchOperations";

public HttpQueryMiddleware(
HttpRequestDelegate next,
HttpRequestExecutorProxy executor,
GraphQLServerOptions baseOptions)
: base(next, executor, baseOptions)
{
}

public async Task InvokeAsync(HttpContext context)
{
if (HttpMethods.IsQuery(context.Request.Method)
&& GetOptions(context).EnableQueryRequests
&& context.ParseContentType() is RequestContentType.Json)
{
var session = await Executor.GetOrCreateSessionAsync(context.RequestAborted);

using (session.DiagnosticEvents.ExecuteHttpRequest(context, HttpRequestKind.HttpQuery))
{
await HandleRequestAsync(context, session);
}

return;
}

// if the request is not a QUERY request the endpoint accepts, or if the content type is
// not correct, we will just invoke the next middleware and do nothing.
await NextAsync(context);
}

private static async Task HandleRequestAsync(HttpContext context, ExecutorSession session)
{
HttpStatusCode? statusCode;
IExecutionResult? result;

// first we validate the accept headers.
var validationResult = MiddlewareHelper.ValidateAcceptContentType(context, session);
var acceptMediaTypes = validationResult.AcceptMediaTypes;

if (!validationResult.IsValid)
{
statusCode = validationResult.StatusCode.Value;
result = validationResult.Error;
goto HANDLE_RESULT;
}

// a QUERY request carries exactly one operation, so an operation batch is refused.
if (context.Request.Query.ContainsKey(BatchOperations))
{
var refused = MiddlewareHelper.CreateBatchingRefusedResult(
ErrorHelper.RequestBatchingNotSupportedForQuery(),
context,
session);
statusCode = refused.StatusCode;
result = refused.Error;
goto HANDLE_RESULT;
}

// next we parse the GraphQL request.
var parserResult = await MiddlewareHelper.ParseSingleRequestFromBodyAsync(context, session);

if (!parserResult.IsValid)
{
statusCode = parserResult.StatusCode;
result = parserResult.Error;
goto HANDLE_RESULT;
}

// a QUERY request carries exactly one variable set, so a variable batch is refused.
if (MiddlewareHelper.IsVariableBatch(parserResult.Request!))
{
var refused = MiddlewareHelper.CreateBatchingRefusedResult(
ErrorHelper.VariableBatchingNotSupportedForQuery(),
context,
session);
statusCode = refused.StatusCode;
result = refused.Error;
goto HANDLE_RESULT;
}

// before we can execute the request we need to determine the request flags.
var requestFlags =
MiddlewareHelper.DetermineHttpQueryRequestFlags(validationResult.RequestFlags);

// next we will execute the request.
var executionResult = await MiddlewareHelper.ExecuteRequestAsync(
parserResult.Request!,
requestFlags,
context,
session);

// a mutation or subscription the executor refused before it ran is answered 422.
statusCode = MiddlewareHelper.DetermineHttpQueryStatusCode(executionResult);
result = executionResult.Result;

HANDLE_RESULT:
await MiddlewareHelper.WriteResultAsync(
result!,
acceptMediaTypes,
statusCode,
context,
session);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,14 @@ namespace HotChocolate.AspNetCore;
/// Answers a request that no GraphQL middleware handled. From the 2026-09-03 revision of the
/// GraphQL over HTTP specification on, a request on the GraphQL endpoint whose method the
/// endpoint does not support is answered 405 with an <c>Allow</c> header, an OPTIONS request
/// is answered 204 with the same header, and a POST request whose Content-Type the endpoint
/// does not support is answered 415. Every other request is answered 404.
/// is answered 204 with the same header, and a POST or QUERY request whose Content-Type the
/// endpoint does not support is answered 415. When QUERY requests are enabled, those responses
/// also carry an <c>Accept-Query</c> header. Every other request is answered 404.
/// </summary>
public sealed class HttpUnsupportedRequestMiddleware : MiddlewareBase
{
private static readonly string s_allowWithGet = string.Join(
", ",
HttpMethods.Get,
HttpMethods.Head,
HttpMethods.Options,
HttpMethods.Post);

private static readonly string s_allowWithoutGet =
string.Join(", ", HttpMethods.Options, HttpMethods.Post);

private readonly PathString? _path;
private string? _allow;

/// <summary>
/// Creates a new instance of <see cref="HttpUnsupportedRequestMiddleware"/>.
Expand Down Expand Up @@ -68,13 +60,13 @@ public async Task InvokeAsync(HttpContext context)

if (session.ReportsUnsupportedMethodOrMediaType)
{
var allow = options.EnableGetRequests ? s_allowWithGet : s_allowWithoutGet;

// a POST that no middleware handled carries a Content-Type the endpoint does
// not support.
if (HttpMethods.IsPost(context.Request.Method))
// a POST, or a QUERY while QUERY is enabled, that no middleware handled carries a
// Content-Type the endpoint does not support.
if (HttpMethods.IsPost(context.Request.Method)
|| (options.EnableQueryRequests && HttpMethods.IsQuery(context.Request.Method)))
{
context.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
WriteAcceptQuery(context.Response, options);
return;
}

Expand All @@ -83,14 +75,16 @@ public async Task InvokeAsync(HttpContext context)
if (HttpMethods.IsOptions(context.Request.Method))
{
context.Response.StatusCode = StatusCodes.Status204NoContent;
context.Response.Headers.Allow = allow;
context.Response.Headers.Allow = GetAllow(options);
WriteAcceptQuery(context.Response, options);
return;
}

// RFC 9110, section 15.5.6 requires a 405 to list the methods the target
// resource supports.
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
context.Response.Headers.Allow = allow;
context.Response.Headers.Allow = GetAllow(options);
WriteAcceptQuery(context.Response, options);
return;
}
}
Expand All @@ -112,4 +106,39 @@ private bool IsGraphQLEndpoint(HttpRequest request)

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);

private static string BuildAllow(GraphQLServerOptions options)
{
var methods = new List<string>(5);

if (options.EnableGetRequests)
{
methods.Add(HttpMethods.Get);
methods.Add(HttpMethods.Head);
}

methods.Add(HttpMethods.Options);
methods.Add(HttpMethods.Post);

if (options.EnableQueryRequests)
{
methods.Add(HttpMethods.Query);
}

return string.Join(", ", methods);
}

// RFC 10008, section 3: Accept-Query advertises the QUERY method and the media types its
// body may use.
private static void WriteAcceptQuery(HttpResponse response, GraphQLServerOptions options)
{
if (options.EnableQueryRequests)
{
response.Headers[HttpHeaderKeys.AcceptQuery] = HttpHeaderValues.AcceptQueryMediaTypes;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ public enum HttpRequestKind
/// <summary>
/// HTTP POST GraphQL-SSE
/// </summary>
HttpSse
HttpSse,

/// <summary>
/// HTTP QUERY GraphQL Request.
/// </summary>
HttpQuery
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ internal static Func<RequestDelegate, RequestDelegate> CreateHttpMultipartMiddle
};
}

internal static Func<RequestDelegate, RequestDelegate> CreateHttpQueryMiddleware(
HttpRequestExecutorProxy executor,
GraphQLServerOptions serverOptions)
{
return next =>
{
var middleware = new HttpQueryMiddleware(next, executor, serverOptions);
return context => middleware.InvokeAsync(context);
};
}

internal static Func<RequestDelegate, RequestDelegate> CreateHttpGetMiddleware(
HttpRequestExecutorProxy executor,
GraphQLServerOptions serverOptions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public sealed class GraphQLServerOptions
/// </summary>
public bool EnforceGetRequestsPreflightHeader { get; set; }

/// <summary>
/// Defines if GraphQL HTTP QUERY requests are allowed.
/// </summary>
public bool EnableQueryRequests { get; set; }

/// <summary>
/// Defines if GraphQL HTTP Multipart requests are allowed.
/// </summary>
Expand Down Expand Up @@ -91,6 +96,7 @@ internal GraphQLServerOptions Clone()
EnableSchemaFileSupport = EnableSchemaFileSupport,
EnableGetRequests = EnableGetRequests,
EnforceGetRequestsPreflightHeader = EnforceGetRequestsPreflightHeader,
EnableQueryRequests = EnableQueryRequests,
EnableMultipartRequests = EnableMultipartRequests,
EnforceMultipartRequestsPreflightHeader = EnforceMultipartRequestsPreflightHeader,
EnforceNullVariableValuesForMultipartFileUpload = EnforceNullVariableValuesForMultipartFileUpload,
Expand Down
Loading
Loading