diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/ErrorHelper.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/ErrorHelper.cs index 93ec40d3e85..66fac473744 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/ErrorHelper.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/ErrorHelper.cs @@ -28,26 +28,36 @@ public static IError NoSupportedAcceptMediaType() public static IQueryResult TypeNameIsEmpty() => QueryResultBuilder.CreateError( new Error( - "The specified types argument is empty.", + AspNetCoreResources.ErrorHelper_TypeNameIsEmpty, code: ErrorCodes.Server.TypeParameterIsEmpty)); public static IQueryResult InvalidTypeName(string typeName) => QueryResultBuilder.CreateError( new Error( - "The type name is invalid.", + AspNetCoreResources.ErrorHelper_InvalidTypeName, code: ErrorCodes.Server.InvalidTypeName, extensions: new Dictionary { - { "typeName", typeName } + { nameof(typeName), typeName } })); public static IQueryResult TypeNotFound(string typeName) => QueryResultBuilder.CreateError( new Error( - $"The type `{typeName}` does not exist.", + string.Format(AspNetCoreResources.ErrorHelper_TypeNotFound, typeName), code: ErrorCodes.Server.TypeDoesNotExist, extensions: new Dictionary { - { "typeName", typeName } + { nameof(typeName), typeName } + })); + + public static IQueryResult InvalidAcceptMediaType(string headerValue) + => QueryResultBuilder.CreateError( + new Error( + string.Format(AspNetCoreResources.ErrorHelper_InvalidAcceptMediaType, headerValue), + code: ErrorCodes.Server.InvalidAcceptHeaderValue, + extensions: new Dictionary + { + { nameof(headerValue), headerValue } })); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HttpResponseExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HttpResponseExtensions.cs index c35d802560c..819b7100869 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HttpResponseExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HttpResponseExtensions.cs @@ -13,7 +13,7 @@ internal static class HttpResponseExtensions private static readonly JsonSerializerOptions _serializerOptions = new() { #if NETCOREAPP3_1 - IgnoreNullValues = true, + IgnoreNullValues = true, #else DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, #endif diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/HeaderUtilities.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/HeaderUtilities.cs index 9e160355a96..244cd998059 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/HeaderUtilities.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/HeaderUtilities.cs @@ -1,7 +1,9 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace HotChocolate.AspNetCore; @@ -14,13 +16,22 @@ internal static class HeaderUtilities private static readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + public static readonly AcceptMediaType[] GraphQLResponseContentTypes = + { + new AcceptMediaType( + ContentType.Types.Application, + ContentType.SubTypes.GraphQLResponse, + null, + StringSegment.Empty) + }; + /// /// Gets the parsed accept header values from a request. /// /// /// The HTTP request. /// - public static AcceptMediaType[] GetAcceptHeader(HttpRequest request) + public static AcceptHeaderResult GetAcceptHeader(HttpRequest request) { if (request.Headers.TryGetValue(HeaderNames.Accept, out var value)) { @@ -28,20 +39,22 @@ public static AcceptMediaType[] GetAcceptHeader(HttpRequest request) if (count == 0) { - return Array.Empty(); + return new AcceptHeaderResult(Array.Empty()); } if (count == 1) { - if (TryParseMediaType(value[0], out var parsedValue)) + var headerValue = value[0]!; + + if (TryParseMediaType(headerValue, out var parsedValue)) { - return new[] { parsedValue }; + return new AcceptHeaderResult(new[] { parsedValue }); } - return Array.Empty(); + return new AcceptHeaderResult(headerValue); } - string[] innerArray = value; + string[] innerArray = value!; ref var searchSpace = ref MemoryMarshal.GetReference(innerArray.AsSpan()); var parsedValues = new AcceptMediaType[innerArray.Length]; var p = 0; @@ -53,6 +66,10 @@ public static AcceptMediaType[] GetAcceptHeader(HttpRequest request) { parsedValues[p++] = parsedValue; } + else + { + return new AcceptHeaderResult(mediaType); + } } if (parsedValues.Length > p) @@ -60,10 +77,10 @@ public static AcceptMediaType[] GetAcceptHeader(HttpRequest request) Array.Resize(ref parsedValues, p); } - return parsedValues; + return new AcceptHeaderResult(parsedValues); } - return Array.Empty(); + return new AcceptHeaderResult(Array.Empty()); } private static bool TryParseMediaType(string s, out AcceptMediaType value) @@ -118,4 +135,30 @@ public CacheEntry(string key, MediaTypeHeaderValue value) public DateTime CreatedAt { get; } } + + internal readonly struct AcceptHeaderResult + { + public AcceptHeaderResult(AcceptMediaType[] acceptMediaTypes) + { + AcceptMediaTypes = acceptMediaTypes; + ErrorResult = null; + HasError = false; + } + + public AcceptHeaderResult(string headerValue) + { + AcceptMediaTypes = Array.Empty(); + ErrorResult = ErrorHelper.InvalidAcceptMediaType(headerValue); + HasError = true; + } + + public AcceptMediaType[] AcceptMediaTypes { get; } + + public IQueryResult? ErrorResult { get; } + +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(ErrorResult))] +#endif + public bool HasError { get; } + } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/HttpGetMiddleware.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/HttpGetMiddleware.cs index fa4744ba1ea..e6f47c97682 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/HttpGetMiddleware.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/HttpGetMiddleware.cs @@ -64,14 +64,39 @@ private async Task HandleRequestAsync(HttpContext context) context.Items[WellKnownContextData.RequestExecutor] = requestExecutor; // next we will inspect the accept headers and determine if we can execute this request. - // if the request defines the accept header value and we cannot meet any of the provided - // media types we will fail the request with 406 Not Acceptable. - var acceptMediaTypes = HeaderUtilities.GetAcceptHeader(context.Request); - var requestFlags = CreateRequestFlags(acceptMediaTypes); + var headerResult = HeaderUtilities.GetAcceptHeader(context.Request); + var acceptMediaTypes = headerResult.AcceptMediaTypes; + // if we cannot parse all media types that we provided we will fail the request + // with a 400 Bad Request. + if (headerResult.HasError) + { + // in this case accept headers were specified and we will + // respond with proper error codes + acceptMediaTypes = HeaderUtilities.GraphQLResponseContentTypes; + statusCode = HttpStatusCode.BadRequest; + +#if NET5_0_OR_GREATER + var errors = headerResult.ErrorResult.Errors!; +#else + var errors = headerResult.ErrorResult!.Errors!; +#endif + result = headerResult.ErrorResult; + _diagnosticEvents.HttpRequestError(context, errors[0]); + goto HANDLE_RESULT; + } + + var requestFlags = CreateRequestFlags(headerResult.AcceptMediaTypes); + + // if the request defines accept header values of which we cannot handle any provided + // media type then we will fail the request with 406 Not Acceptable. if (requestFlags is None) { + // in this case accept headers were specified and we will + // respond with proper error codes + acceptMediaTypes = HeaderUtilities.GraphQLResponseContentTypes; statusCode = HttpStatusCode.NotAcceptable; + var error = ErrorHelper.NoSupportedAcceptMediaType(); result = QueryResultBuilder.CreateError(error); _diagnosticEvents.HttpRequestError(context, error); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/HttpPostMiddlewareBase.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/HttpPostMiddlewareBase.cs index 0b17bd0ca0f..3350625b742 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/HttpPostMiddlewareBase.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/HttpPostMiddlewareBase.cs @@ -2,10 +2,10 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Text; -using Microsoft.AspNetCore.Http; using HotChocolate.AspNetCore.Instrumentation; using HotChocolate.AspNetCore.Serialization; using HotChocolate.Language; +using Microsoft.AspNetCore.Http; using static HotChocolate.Execution.GraphQLRequestFlags; using HttpRequestDelegate = Microsoft.AspNetCore.Http.RequestDelegate; @@ -69,14 +69,39 @@ protected async Task HandleRequestAsync(HttpContext context) context.Items[WellKnownContextData.RequestExecutor] = requestExecutor; // next we will inspect the accept headers and determine if we can execute this request. - // if the request defines the accept header value and we cannot meet any of the provided - // media types we will fail the request with 406 Not Acceptable. - var acceptMediaTypes = HeaderUtilities.GetAcceptHeader(context.Request); - var requestFlags = CreateRequestFlags(acceptMediaTypes); + var headerResult = HeaderUtilities.GetAcceptHeader(context.Request); + var acceptMediaTypes = headerResult.AcceptMediaTypes; + + // if we cannot parse all media types that we provided we will fail the request + // with a 400 Bad Request. + if (headerResult.HasError) + { + // in this case accept headers were specified and we will + // respond with proper error codes + acceptMediaTypes = HeaderUtilities.GraphQLResponseContentTypes; + statusCode = HttpStatusCode.BadRequest; + +#if NET5_0_OR_GREATER + var errors = headerResult.ErrorResult.Errors!; +#else + var errors = headerResult.ErrorResult!.Errors!; +#endif + result = headerResult.ErrorResult; + DiagnosticEvents.HttpRequestError(context, errors[0]); + goto HANDLE_RESULT; + } + + var requestFlags = CreateRequestFlags(headerResult.AcceptMediaTypes); + // if the request defines accept header values of which we cannot handle any provided + // media type then we will fail the request with 406 Not Acceptable. if (requestFlags is None) { + // in this case accept headers were specified and we will + // respond with proper error codes + acceptMediaTypes = HeaderUtilities.GraphQLResponseContentTypes; statusCode = HttpStatusCode.NotAcceptable; + var error = ErrorHelper.NoSupportedAcceptMediaType(); result = QueryResultBuilder.CreateError(error); DiagnosticEvents.HttpRequestError(context, error); @@ -121,13 +146,13 @@ protected async Task HandleRequestAsync(HttpContext context) // if the HTTP request body contains no GraphQL request structure the // whole request is invalid and we will create a GraphQL error response. case 0: - { - statusCode = HttpStatusCode.BadRequest; - var error = errorHandler.Handle(ErrorHelper.RequestHasNoElements()); - result = QueryResultBuilder.CreateError(error); - DiagnosticEvents.HttpRequestError(context, error); - break; - } + { + statusCode = HttpStatusCode.BadRequest; + var error = errorHandler.Handle(ErrorHelper.RequestHasNoElements()); + result = QueryResultBuilder.CreateError(error); + DiagnosticEvents.HttpRequestError(context, error); + break; + } // if the HTTP request body contains a single GraphQL request and we do have // the batch operations query parameter specified we need to execute an @@ -137,32 +162,32 @@ protected async Task HandleRequestAsync(HttpContext context) // contains multiple operations. The batch operation query parameter // defines the order in which the operations shall be executed. case 1 when context.Request.Query.ContainsKey(_batchOperations): - { - string? operationNames = context.Request.Query[_batchOperations]; - - if (!string.IsNullOrEmpty(operationNames) && - TryParseOperations(operationNames, out var ops)) - { - result = await ExecuteOperationBatchAsync( - context, - requestExecutor, - requestInterceptor, - DiagnosticEvents, - requests[0], - requestFlags, - ops); - } - else { - var error = errorHandler.Handle(ErrorHelper.InvalidRequest()); - statusCode = HttpStatusCode.BadRequest; - result = QueryResultBuilder.CreateError(error); - DiagnosticEvents.HttpRequestError(context, error); + string? operationNames = context.Request.Query[_batchOperations]; + + if (!string.IsNullOrEmpty(operationNames) && + TryParseOperations(operationNames, out var ops)) + { + result = await ExecuteOperationBatchAsync( + context, + requestExecutor, + requestInterceptor, + DiagnosticEvents, + requests[0], + requestFlags, + ops); + } + else + { + var error = errorHandler.Handle(ErrorHelper.InvalidRequest()); + statusCode = HttpStatusCode.BadRequest; + result = QueryResultBuilder.CreateError(error); + DiagnosticEvents.HttpRequestError(context, error); + } + + break; } - break; - } - // if the HTTP request body contains a single GraphQL request and // no batch query parameter is specified we need to execute a single // GraphQL request. @@ -170,16 +195,16 @@ protected async Task HandleRequestAsync(HttpContext context) // Most GraphQL requests will be of this type where we want to execute // a single GraphQL query or mutation. case 1: - { - result = await ExecuteSingleAsync( - context, - requestExecutor, - requestInterceptor, - DiagnosticEvents, - requests[0], - requestFlags); - break; - } + { + result = await ExecuteSingleAsync( + context, + requestExecutor, + requestInterceptor, + DiagnosticEvents, + requests[0], + requestFlags); + break; + } // if the HTTP request body contains more than one GraphQL request than // we need to execute a request batch where we need to execute multiple @@ -214,7 +239,7 @@ protected async Task HandleRequestAsync(HttpContext context) DiagnosticEvents.HttpRequestError(context, error); } - HANDLE_RESULT: +HANDLE_RESULT: IDisposable? formatScope = null; try diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs index 1a68d27d4e5..7d804a563c8 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs @@ -260,5 +260,29 @@ internal class AspNetCoreResources { return ResourceManager.GetString("ThrowHelper_Formatter_InvalidAcceptMediaType", resourceCulture); } } + + internal static string ErrorHelper_InvalidAcceptMediaType { + get { + return ResourceManager.GetString("ErrorHelper_InvalidAcceptMediaType", resourceCulture); + } + } + + internal static string ErrorHelper_TypeNotFound { + get { + return ResourceManager.GetString("ErrorHelper_TypeNotFound", resourceCulture); + } + } + + internal static string ErrorHelper_InvalidTypeName { + get { + return ResourceManager.GetString("ErrorHelper_InvalidTypeName", resourceCulture); + } + } + + internal static string ErrorHelper_TypeNameIsEmpty { + get { + return ResourceManager.GetString("ErrorHelper_TypeNameIsEmpty", resourceCulture); + } + } } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.resx b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.resx index 03189fdc745..2dd303237b8 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.resx +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.resx @@ -126,4 +126,16 @@ Invalid accept media types specified. + + Unable to parse the accept header value `{0}`. + + + The type `{0}` does not exist. + + + The type name is invalid. + + + The specified types argument is empty. + diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs index 6a098cbcf27..d9e7b99828c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/GraphQLOverHttpSpecTests.cs @@ -624,6 +624,91 @@ public async Task New_Query_No_Streams_9() {""data"":{""__typename"":""Query""}}"); } + /// + /// This request specifies the application/unsupported content types as accept header value. + /// expected response content-type: application/graphql-response+json + /// expected status code: 400 + /// + [Fact] + public async Task New_Query_No_Streams_10() + { + // arrange + var server = CreateStarWarsServer(); + var client = server.CreateClient(); + + // act + using var request = new HttpRequestMessage(HttpMethod.Post, _url) + { + Content = JsonContent.Create( + new ClientQueryRequest + { + Query = "{ __typename }" + }) + }; + + request.Headers.TryAddWithoutValidation("Accept", "unsupported"); + + using var response = await client.SendAsync(request); + + // assert + // expected response content-type: application/graphql-response+json + // expected status code: 400 + Snapshot + .Create() + .Add(response) + .MatchInline( + @"Headers: + Content-Type: application/graphql-response+json; charset=utf-8 + --------------------------> + Status Code: BadRequest + --------------------------> + {""errors"":[{""message"":""Unable to parse the accept header value " + + @"\u0060unsupported\u0060."",""extensions"":{""headerValue"":""unsupported""," + + @"""code"":""HC0064""}}]}"); + } + + /// + /// This request specifies the application/unsupported content types as accept header value. + /// expected response content-type: application/graphql-response+json + /// expected status code: 206 + /// + [Fact] + public async Task New_Query_No_Streams_12() + { + // arrange + var server = CreateStarWarsServer(); + var client = server.CreateClient(); + + // act + using var request = new HttpRequestMessage(HttpMethod.Post, _url) + { + Content = JsonContent.Create( + new ClientQueryRequest + { + Query = "{ __typename }" + }) + }; + + request.Headers.TryAddWithoutValidation("Accept", "application/unsupported"); + + using var response = await client.SendAsync(request); + + // assert + // expected response content-type: application/graphql-response+json + // expected status code: 206 + Snapshot + .Create() + .Add(response) + .MatchInline( + @"Headers: + Content-Type: application/graphql-response+json; charset=utf-8 + --------------------------> + Status Code: NotAcceptable + --------------------------> + {""errors"":[{""message"":""None of the proved accept header media types " + + @"is supported."",""extensions"":{""code"":""HC0063""}}]}"); + } + /// /// This request specifies the application/graphql-response+json and /// the multipart/mixed content type as accept header value. diff --git a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs index 3b3b553389a..16c5f7c6dc0 100644 --- a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs +++ b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs @@ -176,6 +176,11 @@ public static class Server /// The request did not specify any supported accept media type. /// public const string NoSupportedAcceptMediaType = "HC0063"; + + /// + /// The request did not specify any supported accept media type. + /// + public const string InvalidAcceptHeaderValue = "HC0064"; } public static class Schema