diff --git a/README.md b/README.md index 96bf908f..87412b51 100644 --- a/README.md +++ b/README.md @@ -720,9 +720,11 @@ methods allowing for different options for each configured endpoint. | `ReadFormOnPost` | Enables parsing of form data for POST requests (may have security implications). | False | | `ReadQueryStringOnPost` | Enables parsing the query string on POST requests. | True | | `ReadVariablesFromQueryString` | Enables reading variables from the query string. | True | -| `ValidationErrorsReturnBadRequest` | When enabled, GraphQL requests with validation errors have the HTTP status code set to 400 Bad Request. | True | +| `ValidationErrorsReturnBadRequest` | When enabled, GraphQL requests with validation errors have the HTTP status code set to 400 Bad Request. | Automatic[^1] | | `WebSockets` | Returns a set of configuration properties for WebSocket connections. | | +[^1]: Automatic mode will return a 200 OK status code when the returned content type is `application/json`; otherwise 400 or as defined by the error. + #### GraphQLWebSocketOptions | Property | Description | Default value | diff --git a/docs/migration/migration8.md b/docs/migration/migration8.md index 0a6eb3d2..7182dd9a 100644 --- a/docs/migration/migration8.md +++ b/docs/migration/migration8.md @@ -6,9 +6,17 @@ types for the file by using the new `[MediaType]` attribute on the argument or input object field. - Cross-site request forgery (CSRF) protection has been added for both GET and POST requests, enabled by default. +- Status codes for validation errors are now, by default, determined by the response content type, + and for authentication errors may return a 401 or 403 status code. These changes are purusant + to the [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md). + See the breaking changes section below for more information. ## Breaking changes +- `GraphQLHttpMiddlewareOptions.ValidationErrorsReturnBadRequest` is now a nullable boolean where + `null` means "use the default behavior". The default behavior is to return a 200 status code + when the response content type is `application/json` and a 400 status code otherwise. The + default value for this in v7 was `true`; set this option to retain the v7 behavior. - The validation rules' signatures have changed slightly due to the underlying changes to the GraphQL.NET library. Please see the GraphQL.NET v8 migration document for more information. - The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration @@ -25,6 +33,7 @@ a 400 status code (e.g. the execution of the document has not yet begun), and (2) all errors in the response prefer the same status code. For practical purposes, this means that the included errors triggered by the authorization validation rule will now return 401 or 403 when appropriate. +- The `SelectResponseContentType` method now returns a `MediaTypeHeaderValue` instead of a string. ## Other changes diff --git a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs index 55337936..1608648a 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs @@ -583,7 +583,6 @@ protected virtual async Task HandleRequestAsync( // Normal execution with single graphql request var userContext = await BuildUserContextAsync(context, null); var result = await ExecuteRequestAsync(context, gqlRequest, context.RequestServices, userContext); - HttpStatusCode statusCode = HttpStatusCode.OK; // when the request fails validation (this logic does not apply to execution errors) if (!result.Executed) { @@ -591,21 +590,11 @@ protected virtual async Task HandleRequestAsync( // even though it occurs during validation (because the query text must be parsed to know if the request is a query or a mutation) if (result.Errors?.Any(e => e is HttpMethodValidationError) == true) { - statusCode = HttpStatusCode.MethodNotAllowed; - } - // otherwise use 4xx error codes when configured to do so - else if (_options.ValidationErrorsReturnBadRequest) - { - statusCode = HttpStatusCode.BadRequest; - // if all errors being returned prefer the same status code, use that - if (result.Errors?.Count > 0 && result.Errors[0] is IHasPreferredStatusCode initialError) - { - if (result.Errors.All(e => e is IHasPreferredStatusCode e2 && e2.PreferredStatusCode == initialError.PreferredStatusCode)) - statusCode = initialError.PreferredStatusCode; - } + await WriteJsonResponseAsync(context, HttpStatusCode.MethodNotAllowed, result); + return; } } - await WriteJsonResponseAsync(context, statusCode, result); + await WriteJsonResponseAsync(context, result); } /// @@ -750,10 +739,11 @@ protected virtual async Task ExecuteRequestAsync(HttpContext co ValueTask?> IUserContextBuilder.BuildUserContextAsync(HttpContext context, object? payload) => BuildUserContextAsync(context, payload); + private static readonly MediaTypeHeaderValueMs _applicationJsonMediaType = MediaTypeHeaderValueMs.Parse(CONTENTTYPE_JSON); private static readonly MediaTypeHeaderValueMs[] _validMediaTypes = new[] { MediaTypeHeaderValueMs.Parse(CONTENTTYPE_GRAPHQLRESPONSEJSON), - MediaTypeHeaderValueMs.Parse(CONTENTTYPE_JSON), + _applicationJsonMediaType, MediaTypeHeaderValueMs.Parse(CONTENTTYPE_GRAPHQLJSON), // deprecated }; @@ -771,62 +761,87 @@ protected virtual async Task ExecuteRequestAsync(HttpContext co /// For more complex behavior patterns, override /// . /// - protected virtual string SelectResponseContentType(HttpContext context) + protected virtual MediaTypeHeaderValueMs SelectResponseContentType(HttpContext context) { // pull the Accept header, which may contain multiple content types var acceptHeaders = context.Request.Headers.ContainsKey(Microsoft.Net.Http.Headers.HeaderNames.Accept) ? context.Request.GetTypedHeaders().Accept : Array.Empty(); - if (acceptHeaders.Count > 0) + if (acceptHeaders.Count == 1) + { + var response = IsSupportedMediaType(acceptHeaders[0]); + if (response != null) + return response; + } + else if (acceptHeaders.Count > 0) { // enumerate through each content type and see if it matches a supported content type // give priority to specific types, then to types with wildcards foreach (var acceptHeader in acceptHeaders.OrderBy(x => x.MatchesAllTypes ? 4 : x.MatchesAllSubTypes ? 3 : x.MatchesAllSubTypesWithoutSuffix ? 2 : 1)) { - var response = CheckForMatch(acceptHeader); + var response = IsSupportedMediaType(acceptHeader); if (response != null) return response; } } // return the default content type if no match is found, or if there is no 'Accept' header - return _options.DefaultResponseContentTypeString; + return _options.DefaultResponseContentType; + } - string? CheckForMatch(MediaTypeHeaderValueMs acceptHeader) - { - // strip quotes from charset - if (acceptHeader.Charset.Length > 0 && acceptHeader.Charset[0] == '\"' && acceptHeader.Charset[acceptHeader.Charset.Length - 1] == '\"') - { - acceptHeader.Charset = acceptHeader.Charset.Substring(1, acceptHeader.Charset.Length - 2); - } + /// + /// Checks to see if the specified matches any of the supported content types + /// by this middleware. If a match is found, the matching content type is returned; otherwise, . + /// Prioritizes , then + /// application/graphql-response+json, then application/json. + /// + private MediaTypeHeaderValueMs? IsSupportedMediaType(MediaTypeHeaderValueMs acceptHeader) + => IsSupportedMediaType(acceptHeader, _options.DefaultResponseContentType, _validMediaTypes); - // check if this matches the default content type header - if (IsSubsetOf(_options.DefaultResponseContentType, acceptHeader)) - return _options.DefaultResponseContentTypeString; + /// + /// Checks to see if the specified matches any of the supported content types + /// by this middleware. If a match is found, the matching content type is returned; otherwise, . + /// Prioritizes , then + /// application/graphql-response+json, then application/json. + /// + private static MediaTypeHeaderValueMs? IsSupportedMediaType(MediaTypeHeaderValueMs acceptHeader, MediaTypeHeaderValueMs preferredContentType, MediaTypeHeaderValueMs[] allowedContentTypes) + { + // speeds check in WriteJsonResponseAsync + if (acceptHeader == preferredContentType) + return preferredContentType; - // if the default content type header does not contain a charset, test with utf-8 as the charset - if (_options.DefaultResponseContentType.Charset.Length == 0) - { - var contentType2 = _options.DefaultResponseContentType.Copy(); - contentType2.Charset = "utf-8"; - if (IsSubsetOf(contentType2, acceptHeader)) - return contentType2.ToString(); - } + // strip quotes from charset + if (acceptHeader.Charset.Length > 0 && acceptHeader.Charset[0] == '\"' && acceptHeader.Charset[acceptHeader.Charset.Length - 1] == '\"') + { + acceptHeader.Charset = acceptHeader.Charset.Substring(1, acceptHeader.Charset.Length - 2); + } - // loop through the other supported media types, attempting to find a match - for (int j = 0; j < _validMediaTypes.Length; j++) - { - var mediaType = _validMediaTypes[j]; - if (IsSubsetOf(mediaType, acceptHeader)) - // when a match is found, return the match - return mediaType.ToString(); - } + // check if this matches the default content type header + if (IsSubsetOf(preferredContentType, acceptHeader)) + return preferredContentType; + + // if the default content type header does not contain a charset, test with utf-8 as the charset + if (preferredContentType.Charset.Length == 0) + { + var contentType2 = preferredContentType.Copy(); + contentType2.Charset = "utf-8"; + if (IsSubsetOf(contentType2, acceptHeader)) + return contentType2; + } - // no match - return null; + // loop through the other supported media types, attempting to find a match + for (int j = 0; j < allowedContentTypes.Length; j++) + { + var mediaType = allowedContentTypes[j]; + if (IsSubsetOf(mediaType, acceptHeader)) + // when a match is found, return the match + return mediaType; } + // no match + return null; + // --- note: the below functions were copied from ASP.NET Core 2.1 source --- // see https://github.com/dotnet/aspnetcore/blob/v2.1.33/src/Http/Headers/src/MediaTypeHeaderValue.cs @@ -940,11 +955,41 @@ static bool MatchesSubtypeSuffix(MediaTypeHeaderValueMs mediaType, MediaTypeHead } /// - /// Writes the specified object (usually a GraphQL response represented as an instance of ) as JSON to the HTTP response stream. + /// Writes the specified as JSON to the HTTP response stream, + /// selecting the proper content type and status code based on the request Accept header and response. + /// + protected virtual Task WriteJsonResponseAsync(HttpContext context, ExecutionResult result) + { + var contentType = SelectResponseContentType(context); + context.Response.ContentType = contentType == _options.DefaultResponseContentType ? _options.DefaultResponseContentTypeString : contentType.ToString(); + context.Response.StatusCode = (int)HttpStatusCode.OK; + if (result.Executed == false) + { + var useBadRequest = _options.ValidationErrorsReturnBadRequest ?? IsSupportedMediaType(contentType, _applicationJsonMediaType, Array.Empty()) == null; + if (useBadRequest) + { + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + + // if all errors being returned prefer the same status code, use that + if (result.Errors?.Count > 0 && result.Errors[0] is IHasPreferredStatusCode initialError) + { + if (result.Errors.All(e => e is IHasPreferredStatusCode e2 && e2.PreferredStatusCode == initialError.PreferredStatusCode)) + context.Response.StatusCode = (int)initialError.PreferredStatusCode; + } + } + } + + return _serializer.WriteAsync(context.Response.Body, result, context.RequestAborted); + } + + /// + /// Writes the specified object (usually a GraphQL response represented as an instance of ) + /// as JSON to the HTTP response stream, using the specified status code. /// protected virtual Task WriteJsonResponseAsync(HttpContext context, HttpStatusCode httpStatusCode, TResult result) { - context.Response.ContentType = SelectResponseContentType(context); + var contentType = SelectResponseContentType(context); + context.Response.ContentType = contentType == _options.DefaultResponseContentType ? _options.DefaultResponseContentTypeString : contentType.ToString(); context.Response.StatusCode = (int)httpStatusCode; return _serializer.WriteAsync(context.Response.Body, result, context.RequestAborted); diff --git a/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs index 93c76596..a90c89fa 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs @@ -44,12 +44,22 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions /// /// When enabled, GraphQL requests with validation errors have the HTTP status code - /// set to 400 Bad Request or the error status code dictated by the error. - /// GraphQL requests with execution errors are unaffected. + /// set to 400 Bad Request or the error status code dictated by the error, while + /// setting this to false will use a 200 status code for all responses. + ///

+ /// GraphQL requests with execution errors are unaffected and return a 200 status code. + ///

+ /// Transport errors, such as a transport-level authentication failure, are not affected + /// and return a error-specific status code, such as 405 Method Not Allowed if a mutation + /// is attempted over a HTTP GET connection. ///

/// Does not apply to batched or WebSocket requests. + ///

+ /// Settings this to will use a 200 status code for + /// application/json responses and use a 4xx status code for + /// application/graphql-response+json and other responses. ///
- public bool ValidationErrorsReturnBadRequest { get; set; } = true; + public bool? ValidationErrorsReturnBadRequest { get; set; } /// /// Enables parsing the query string on POST requests. diff --git a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt index 62fe0990..66e0ca97 100644 --- a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -116,10 +116,11 @@ namespace GraphQL.Server.Transports.AspNetCore "SingleRequest", "BatchRequest"})] protected virtual System.Threading.Tasks.Task?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { } - protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } + protected virtual Microsoft.Net.Http.Headers.MediaTypeHeaderValue SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { } + protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionResult result) { } protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { } } public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions @@ -143,7 +144,7 @@ namespace GraphQL.Server.Transports.AspNetCore public bool ReadFormOnPost { get; set; } public bool ReadQueryStringOnPost { get; set; } public bool ReadVariablesFromQueryString { get; set; } - public bool ValidationErrorsReturnBadRequest { get; set; } + public bool? ValidationErrorsReturnBadRequest { get; set; } public GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions WebSockets { get; set; } } public class GraphQLHttpMiddleware : GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddleware diff --git a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt index 485e3fa7..98c889b4 100644 --- a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -123,10 +123,11 @@ namespace GraphQL.Server.Transports.AspNetCore "SingleRequest", "BatchRequest"})] protected virtual System.Threading.Tasks.Task?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { } - protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } + protected virtual Microsoft.Net.Http.Headers.MediaTypeHeaderValue SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { } + protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionResult result) { } protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { } } public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions @@ -150,7 +151,7 @@ namespace GraphQL.Server.Transports.AspNetCore public bool ReadFormOnPost { get; set; } public bool ReadQueryStringOnPost { get; set; } public bool ReadVariablesFromQueryString { get; set; } - public bool ValidationErrorsReturnBadRequest { get; set; } + public bool? ValidationErrorsReturnBadRequest { get; set; } public GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions WebSockets { get; set; } } public class GraphQLHttpMiddleware : GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddleware diff --git a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt index c60e890a..08585061 100644 --- a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -116,10 +116,11 @@ namespace GraphQL.Server.Transports.AspNetCore "SingleRequest", "BatchRequest"})] protected virtual System.Threading.Tasks.Task?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { } - protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } + protected virtual Microsoft.Net.Http.Headers.MediaTypeHeaderValue SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { } protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { } + protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionResult result) { } protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { } } public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions @@ -143,7 +144,7 @@ namespace GraphQL.Server.Transports.AspNetCore public bool ReadFormOnPost { get; set; } public bool ReadQueryStringOnPost { get; set; } public bool ReadVariablesFromQueryString { get; set; } - public bool ValidationErrorsReturnBadRequest { get; set; } + public bool? ValidationErrorsReturnBadRequest { get; set; } public GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions WebSockets { get; set; } } public class GraphQLHttpMiddleware : GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddleware diff --git a/tests/Transports.AspNetCore.Tests/Middleware/GetTests.cs b/tests/Transports.AspNetCore.Tests/Middleware/GetTests.cs index dfe83148..949750a4 100644 --- a/tests/Transports.AspNetCore.Tests/Middleware/GetTests.cs +++ b/tests/Transports.AspNetCore.Tests/Middleware/GetTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using GraphQL.Server.Transports.AspNetCore.Errors; using GraphQL.Validation; @@ -241,14 +242,26 @@ public async Task NoUseWebSockets() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task WithError(bool badRequest) + [InlineData(false, false, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(false, false, "application/json", "application/json; charset=utf-8")] + [InlineData(true, true, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(true, true, "application/json", "application/json; charset=utf-8")] + [InlineData(null, true, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, true, "application/graphql-response+json; charset=utf-8", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, true, "text/text", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, false, "application/json; charset=utf-8", "application/json; charset=utf-8")] + [InlineData(null, false, "application/json", "application/json; charset=utf-8")] + public async Task WithError(bool? badRequest, bool expectBadRequest, string accept, string contentType) { _options.ValidationErrorsReturnBadRequest = badRequest; var client = _server.CreateClient(); - using var response = await client.GetAsync("/graphql?query={invalid}"); - await response.ShouldBeAsync(badRequest, """{"errors":[{"message":"Cannot query field \u0027invalid\u0027 on type \u0027Query\u0027.","locations":[{"line":1,"column":2}],"extensions":{"code":"FIELDS_ON_CORRECT_TYPE","codes":["FIELDS_ON_CORRECT_TYPE"],"number":"5.3.1"}}]}"""); + using var request = new HttpRequestMessage(HttpMethod.Get, "/graphql?query={invalid}"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(accept)); + using var response = await client.SendAsync(request); + await response.ShouldBeAsync( + contentType, + expectBadRequest ? HttpStatusCode.BadRequest : HttpStatusCode.OK, + """{"errors":[{"message":"Cannot query field \u0027invalid\u0027 on type \u0027Query\u0027.","locations":[{"line":1,"column":2}],"extensions":{"code":"FIELDS_ON_CORRECT_TYPE","codes":["FIELDS_ON_CORRECT_TYPE"],"number":"5.3.1"}}]}"""); } [Fact] diff --git a/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs b/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs index 2aff9345..b591cdbc 100644 --- a/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs +++ b/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using GraphQL.Server.Transports.AspNetCore.Errors; using GraphQL.Validation; @@ -535,13 +536,27 @@ public async Task CannotParseContentType(bool badRequest) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task WithError(bool badRequest) + [InlineData(false, false, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(false, false, "application/json", "application/json; charset=utf-8")] + [InlineData(true, true, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(true, true, "application/json", "application/json; charset=utf-8")] + [InlineData(null, true, "application/graphql-response+json", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, true, "application/graphql-response+json; charset=utf-8", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, true, "text/text", "application/graphql-response+json; charset=utf-8")] + [InlineData(null, false, "application/json; charset=utf-8", "application/json; charset=utf-8")] + [InlineData(null, false, "application/json", "application/json; charset=utf-8")] + public async Task WithError(bool? badRequest, bool expectBadRequest, string accept, string contentType) { _options.ValidationErrorsReturnBadRequest = badRequest; - using var response = await PostRequestAsync(new() { Query = "{invalid}" }); - await response.ShouldBeAsync(badRequest, """{"errors":[{"message":"Cannot query field \u0027invalid\u0027 on type \u0027Query\u0027.","locations":[{"line":1,"column":2}],"extensions":{"code":"FIELDS_ON_CORRECT_TYPE","codes":["FIELDS_ON_CORRECT_TYPE"],"number":"5.3.1"}}]}"""); + var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/graphql"); + request.Content = new StringContent(new GraphQLSerializer().Serialize(new GraphQLRequest { Query = "{invalid}" }), Encoding.UTF8, "application/json"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(accept)); + using var response = await client.SendAsync(request); + await response.ShouldBeAsync( + contentType, + expectBadRequest ? HttpStatusCode.BadRequest : HttpStatusCode.OK, + """{"errors":[{"message":"Cannot query field \u0027invalid\u0027 on type \u0027Query\u0027.","locations":[{"line":1,"column":2}],"extensions":{"code":"FIELDS_ON_CORRECT_TYPE","codes":["FIELDS_ON_CORRECT_TYPE"],"number":"5.3.1"}}]}"""); } [Fact] diff --git a/tests/Transports.AspNetCore.Tests/ShouldlyExtensions.cs b/tests/Transports.AspNetCore.Tests/ShouldlyExtensions.cs index 3ec2b915..0cf42063 100644 --- a/tests/Transports.AspNetCore.Tests/ShouldlyExtensions.cs +++ b/tests/Transports.AspNetCore.Tests/ShouldlyExtensions.cs @@ -10,13 +10,13 @@ public static Task ShouldBeAsync(this HttpResponseMessage message, bool badReque public static Task ShouldBeAsync(this HttpResponseMessage message, string expectedResponse) => ShouldBeAsync(message, HttpStatusCode.OK, expectedResponse); - public static async Task ShouldBeAsync(this HttpResponseMessage message, HttpStatusCode httpStatusCode, string expectedResponse) + public static Task ShouldBeAsync(this HttpResponseMessage message, HttpStatusCode httpStatusCode, string expectedResponse) + => ShouldBeAsync(message, "application/graphql-response+json; charset=utf-8", httpStatusCode, expectedResponse); + + public static async Task ShouldBeAsync(this HttpResponseMessage message, string contentType, HttpStatusCode httpStatusCode, string expectedResponse) { message.StatusCode.ShouldBe(httpStatusCode); - var contentType = message.Content.Headers.ContentType; - contentType.ShouldNotBeNull(); - contentType.MediaType.ShouldBe("application/graphql-response+json"); - contentType.CharSet.ShouldBe("utf-8"); + (message.Content.Headers.ContentType?.ToString()).ShouldBe(contentType); var actualResponse = await message.Content.ReadAsStringAsync(); actualResponse.ShouldBe(expectedResponse); }