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 @@ -135,6 +135,7 @@ public static IApplicationBuilder MapGraphQL(
applicationBuilder
.Use(MiddlewareFactory.CreateCancellationMiddleware())
.Use(MiddlewareFactory.CreateWebSocketSubscriptionMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path))
.Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions))
.Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions))
Expand Down Expand Up @@ -216,6 +217,7 @@ public static GraphQLHttpEndpointConventionBuilder MapGraphQLHttp(

requestPipeline
.Use(MiddlewareFactory.CreateCancellationMiddleware())
.Use(MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path: null))
.Use(MiddlewareFactory.CreateHttpPostMiddleware(executor, serverOptions))
.Use(MiddlewareFactory.CreateHttpMultipartMiddleware(executor, serverOptions, formOptions))
.Use(MiddlewareFactory.CreateHttpQueryMiddleware(executor, serverOptions))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ internal static bool AcceptHeaderContainsHtml(this HttpRequest request)
&& values.Count > 0 && (values[0]?.Contains(ContentType.Html) ?? false);
}

internal static bool IsEndpointPath(this HttpRequest request, PathString path)
{
var isBelowPath = request.Path.StartsWithSegments(
path,
StringComparison.OrdinalIgnoreCase,
out var remaining);

return isBelowPath && remaining.Value is null or "" or "/";
}

internal static bool IsGetOrHeadMethod(this HttpRequest request)
{
return HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using static System.Globalization.CultureInfo;
using static System.String;

Expand All @@ -9,6 +10,52 @@ internal static class HttpResponseExtensions
private const string ContentDispositionHeader = "Content-Disposition";
private const string ContentDispositionValue = "attachment; filename=\"{0}\"";

/// <summary>
/// Adds each comma-separated field name in <paramref name="fieldNames"/> to the <c>Vary</c>
/// header unless the header already lists it, compared case-insensitively, or lists <c>*</c>.
/// </summary>
public static void AppendVary(this IHeaderDictionary headers, string fieldNames)
{
var vary = headers.Vary;
var remaining = fieldNames.AsSpan();
var total = 0;
var listed = 0;

while (TryReadFieldName(ref remaining, out var fieldName))
{
total++;

if (ListsFieldName(vary, fieldName))
{
listed++;
}
}

if (listed == total)
{
return;
}

if (listed == 0)
{
headers.Vary = StringValues.Concat(vary, fieldNames);
return;
}

var missing = new List<string>(total - listed);
remaining = fieldNames.AsSpan();

while (TryReadFieldName(ref remaining, out var fieldName))
{
if (!ListsFieldName(vary, fieldName))
{
missing.Add(fieldName.ToString());
}
}

headers.Vary = StringValues.Concat(vary, Join(", ", missing));
}

public static IHeaderDictionary SetContentDisposition(
this IHeaderDictionary headers,
string fileName)
Expand All @@ -17,4 +64,43 @@ public static IHeaderDictionary SetContentDisposition(
Format(InvariantCulture, ContentDispositionValue, fileName);
return headers;
}

private static bool ListsFieldName(StringValues values, ReadOnlySpan<char> fieldName)
{
foreach (var value in values)
{
var remaining = value.AsSpan();

while (TryReadFieldName(ref remaining, out var listedFieldName))
{
if (listedFieldName is "*"
|| listedFieldName.Equals(fieldName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}

return false;
}

private static bool TryReadFieldName(
ref ReadOnlySpan<char> remaining,
out ReadOnlySpan<char> fieldName)
{
while (!remaining.IsEmpty)
{
var comma = remaining.IndexOf(',');
fieldName = (comma < 0 ? remaining : remaining[..comma]).Trim();
remaining = comma < 0 ? [] : remaining[(comma + 1)..];

if (!fieldName.IsEmpty)
{
return true;
}
}

fieldName = default;
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ private async ValueTask FormatInternalAsync(
if (result.ContextData.TryGetValue(ExecutionContextData.VaryHeaderValue, out var varyValue)
&& varyValue is string varyHeaderValue)
{
response.Headers.Vary = varyHeaderValue;
response.Headers.AppendVary(varyHeaderValue);
}

OnWriteResponseHeaders(operationResult, format, response.Headers);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using RequestDelegate = Microsoft.AspNetCore.Http.RequestDelegate;

namespace HotChocolate.AspNetCore;

/// <summary>
/// Declares <c>Accept</c> as a selecting header, through <c>Vary</c>, on every request whose
/// response the GraphQL endpoint selects by its <c>Accept</c> header: a GET, HEAD, POST, or QUERY
/// on the endpoint.
/// </summary>
/// <param name="next">
/// The next middleware in line.
/// </param>
/// <param name="path">
/// The path of the GraphQL endpoint, or <c>null</c> when every request that reaches this
/// middleware is on the endpoint.
/// </param>
internal sealed class HttpContentNegotiationMiddleware(RequestDelegate next, PathString? path)
{
public Task InvokeAsync(HttpContext context)
{
var request = context.Request;

if ((path is not { } endpointPath || request.IsEndpointPath(endpointPath))
&& (HttpMethods.IsGet(request.Method)
|| HttpMethods.IsHead(request.Method)
|| HttpMethods.IsPost(request.Method)
|| HttpMethods.IsQuery(request.Method)))
{
context.Response.Headers.AppendVary(HeaderNames.Accept);
}

return next(context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public HttpUnsupportedRequestMiddleware(

public async Task InvokeAsync(HttpContext context)
{
if (IsGraphQLEndpoint(context.Request))
if (_path is not { } path || context.Request.IsEndpointPath(path))
{
var options = GetOptions(context);

Expand Down Expand Up @@ -92,21 +92,6 @@ public async Task InvokeAsync(HttpContext context)
context.Response.StatusCode = StatusCodes.Status404NotFound;
}

private bool IsGraphQLEndpoint(HttpRequest request)
{
if (_path is not { } path)
{
return true;
}

var isBelowPath = request.Path.StartsWithSegments(
path,
StringComparison.OrdinalIgnoreCase,
out var remaining);

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ internal static Func<RequestDelegate, RequestDelegate> CreateWebSocketSubscripti
};
}

internal static Func<RequestDelegate, RequestDelegate> CreateHttpContentNegotiationMiddleware(
PathString? path)
{
return next =>
{
var middleware = new HttpContentNegotiationMiddleware(next, path);
return context => middleware.InvokeAsync(context);
};
}

internal static Func<RequestDelegate, RequestDelegate> CreateHttpPostMiddleware(
HttpRequestExecutorProxy executor,
GraphQLServerOptions serverOptions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ internal static void MapPersistedOperationMiddleware(
{
var executorProxy = HttpRequestExecutorProxy.Create(services, schemaName);
var serverOptions = services.GetRequiredService<IOptionsMonitor<GraphQLServerOptions>>().Get(schemaName);
var negotiation = MiddlewareFactory.CreateHttpContentNegotiationMiddleware(path: null);

((IEndpointConventionBuilder)groupBuilder).Add(
endpoint => endpoint.RequestDelegate = negotiation(endpoint.RequestDelegate!));

groupBuilder.MapGet(
"/{operationId}",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Http;

namespace HotChocolate.AspNetCore.Extensions;

public sealed class HttpResponseExtensionsTests
{
[Theory]
[InlineData(new string[0], "Accept", new[] { "Accept" })]
[InlineData(new[] { "Origin" }, "Accept", new[] { "Origin", "Accept" })]
[InlineData(new[] { "accept" }, "Accept", new[] { "accept" })]
[InlineData(new[] { "Accept" }, "x-bar, x-foo", new[] { "Accept", "x-bar, x-foo" })]
[InlineData(new[] { "Accept" }, "accept, x-foo", new[] { "Accept", "x-foo" })]
[InlineData(new[] { "Origin, Accept" }, "ACCEPT", new[] { "Origin, Accept" })]
[InlineData(new[] { "Origin, , Accept" }, "accept", new[] { "Origin, , Accept" })]
[InlineData(new[] { "*" }, "Accept", new[] { "*" })]
[InlineData(new[] { "Origin", "*" }, "Accept, x-foo", new[] { "Origin", "*" })]
public void AppendVary_Should_AddOnlyNamesNotYetListed_When_NamesAreGiven(
string[] existing,
string fieldNames,
string[] expected)
{
// arrange
IHeaderDictionary headers = new HeaderDictionary();

if (existing.Length > 0)
{
headers.Vary = existing;
}

// act
headers.AppendVary(fieldNames);

// assert
Assert.Equal(expected, headers.Vary.ToArray());
}
}
Loading
Loading