diff --git a/migration.md b/migration.md index 325fe0d..d7b3057 100644 --- a/migration.md +++ b/migration.md @@ -1,5 +1,37 @@ # Version history / migration notes +## 8.0.0 + +### New features + +- Response content type negotiation now properly handles the `Accept` header, supporting + `application/graphql-response+json`, `application/json`, and `application/graphql+json` (deprecated). +- 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 pursuant + 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. +- Validation errors such as authentication errors may now be returned with a 'preferred' status + code instead of a 400 status code. This occurs when (1) the response would otherwise contain + 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. +- The default response content type is now `application/graphql-response+json` (configurable via + `GraphQLHttpMiddlewareOptions.DefaultResponseContentType`), which is the new standard per the + GraphQL over HTTP specification. + +### Other changes + +- Added deprecation comments to `MEDIATYPE_GRAPHQLJSON` and `CONTENTTYPE_GRAPHQLJSON` constants + as `application/graphql+json` is being phased out in favor of `application/graphql-response+json`. + ## 7.0.0 GraphQL.AspNetCore3 v7 requires GraphQL.NET v8 or newer. @@ -33,10 +65,6 @@ GraphQL.AspNetCore3 v6 requires GraphQL.NET v8 or newer. ### 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. - Cross-site request forgery (CSRF) protection has been enabled for all requests by default. @@ -46,12 +74,6 @@ GraphQL.AspNetCore3 v6 requires GraphQL.NET v8 or newer. the `CsrfProtectionHeaders` property on the same class. See the readme for more details. - Form POST requests are disabled by default; to enable them, set the `ReadFormOnPost` setting to `true`. -- Validation errors such as authentication errors may now be returned with a 'preferred' status - code instead of a 400 status code. This occurs when (1) the response would otherwise contain - 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. - The `AuthorizationVisitorBase.GetRecursivelyReferencedUsedFragments` method has been removed as `ValidationContext` now provides an overload to `GetRecursivelyReferencedFragments` which will only return fragments in use by the specified operation. diff --git a/src/GraphQL.AspNetCore3/AuthorizationVisitorBase.Validation.cs b/src/GraphQL.AspNetCore3/AuthorizationVisitorBase.Validation.cs index cc5a9e5..17e28be 100644 --- a/src/GraphQL.AspNetCore3/AuthorizationVisitorBase.Validation.cs +++ b/src/GraphQL.AspNetCore3/AuthorizationVisitorBase.Validation.cs @@ -131,6 +131,7 @@ protected virtual void HandleNodeNotAuthorized(ValidationInfo info) { var resource = GenerateResourceDescription(info); var err = info.Node == null ? new AccessDeniedError(resource) : new AccessDeniedError(resource, info.Context.Document.Source, info.Node); + err.PreferredStatusCode = HttpStatusCode.Unauthorized; info.Context.ReportError(err); } diff --git a/src/GraphQL.AspNetCore3/Errors/AccessDeniedError.cs b/src/GraphQL.AspNetCore3/Errors/AccessDeniedError.cs index 5d7b9f5..2c1e560 100644 --- a/src/GraphQL.AspNetCore3/Errors/AccessDeniedError.cs +++ b/src/GraphQL.AspNetCore3/Errors/AccessDeniedError.cs @@ -5,7 +5,7 @@ namespace GraphQL.AspNetCore3.Errors; /// /// Represents an error indicating that the user is not allowed access to the specified resource. /// -public class AccessDeniedError : ValidationError +public class AccessDeniedError : ValidationError, IHasPreferredStatusCode { /// public AccessDeniedError(string resource) @@ -31,4 +31,7 @@ public AccessDeniedError(string resource, GraphQLParser.ROM originalQuery, param /// Returns the list of role memberships that would allow access to these node(s). /// public List? RolesRequired { get; set; } + + /// + public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.Forbidden; } diff --git a/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs b/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs index d096b98..a66b8ee 100644 --- a/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs +++ b/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs @@ -14,5 +14,5 @@ public FileCountExceededError() } /// - public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge; + public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.RequestEntityTooLarge; } diff --git a/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs b/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs index 4f85375..0a44868 100644 --- a/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs +++ b/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs @@ -14,5 +14,5 @@ public FileSizeExceededError() } /// - public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge; + public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.RequestEntityTooLarge; } diff --git a/src/GraphQL.AspNetCore3/Errors/HttpMethodValidationError.cs b/src/GraphQL.AspNetCore3/Errors/HttpMethodValidationError.cs index fbad296..e689a7d 100644 --- a/src/GraphQL.AspNetCore3/Errors/HttpMethodValidationError.cs +++ b/src/GraphQL.AspNetCore3/Errors/HttpMethodValidationError.cs @@ -4,11 +4,14 @@ namespace GraphQL.AspNetCore3.Errors; /// Represents a validation error indicating that the requested operation is not valid /// for the type of HTTP request. /// -public class HttpMethodValidationError : ValidationError +public class HttpMethodValidationError : ValidationError, IHasPreferredStatusCode { /// public HttpMethodValidationError(GraphQLParser.ROM originalQuery, ASTNode node, string message) : base(originalQuery, null!, message, node) { } + + /// + public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.MethodNotAllowed; } diff --git a/src/GraphQL.AspNetCore3/Errors/InvalidContentTypeError.cs b/src/GraphQL.AspNetCore3/Errors/InvalidContentTypeError.cs index 627c4a8..28a755d 100644 --- a/src/GraphQL.AspNetCore3/Errors/InvalidContentTypeError.cs +++ b/src/GraphQL.AspNetCore3/Errors/InvalidContentTypeError.cs @@ -3,11 +3,14 @@ namespace GraphQL.AspNetCore3.Errors; /// /// Represents an error indicating that the content-type was invalid. /// -public class InvalidContentTypeError : RequestError +public class InvalidContentTypeError : RequestError, IHasPreferredStatusCode { /// public InvalidContentTypeError() : base("Invalid 'Content-Type' header.") { } /// public InvalidContentTypeError(string message) : base("Invalid 'Content-Type' header: " + message) { } + + /// + public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.UnsupportedMediaType; } diff --git a/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs b/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs index 98289b3..05821e1 100644 --- a/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs +++ b/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs @@ -5,9 +5,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Primitives; #if NETSTANDARD2_0 || NETCOREAPP2_1 using IHostApplicationLifetime = Microsoft.Extensions.Hosting.IApplicationLifetime; #endif +using MediaTypeHeaderValueMs = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; namespace GraphQL.AspNetCore3; @@ -72,10 +74,12 @@ public class GraphQLHttpMiddleware : IUserContextBuilder private const string DOCUMENT_ID_KEY = "documentId"; private const string OPERATIONS_KEY = "operations"; // used for multipart/form-data requests per https://github.com/jaydenseric/graphql-multipart-request-spec private const string MAP_KEY = "map"; // used for multipart/form-data requests per https://github.com/jaydenseric/graphql-multipart-request-spec - private const string MEDIATYPE_GRAPHQLJSON = "application/graphql+json"; + private const string MEDIATYPE_GRAPHQLJSON = "application/graphql+json"; // deprecated private const string MEDIATYPE_JSON = "application/json"; private const string MEDIATYPE_GRAPHQL = "application/graphql"; - private const string CONTENTTYPE_GRAPHQLJSON = "application/graphql+json; charset=utf-8"; + private const string CONTENTTYPE_JSON = "application/json; charset=utf-8"; + private const string CONTENTTYPE_GRAPHQLJSON = "application/graphql+json; charset=utf-8"; // deprecated + internal const string CONTENTTYPE_GRAPHQLRESPONSEJSON = "application/graphql-response+json; charset=utf-8"; /// /// Initializes a new instance. @@ -202,7 +206,7 @@ public virtual async Task InvokeAsync(HttpContext context) var httpRequest = context.Request; switch (mediaType?.ToLowerInvariant()) { - case MEDIATYPE_GRAPHQLJSON: + case MEDIATYPE_GRAPHQLJSON: // deprecated case MEDIATYPE_JSON: IList? deserializationResult; try { @@ -237,7 +241,7 @@ public virtual async Task InvokeAsync(HttpContext context) var formCollection = await httpRequest.ReadFormAsync(context.RequestAborted); return ReadFormContent(formCollection); } catch (ExecutionError ex) { // catches FileCountExceededError, FileSizeExceededError, InvalidMapError - await WriteErrorResponseAsync(context, ex is IHasPreferredStatusCode sc ? sc.PreferredStatusCode : HttpStatusCode.BadRequest, ex); + await WriteErrorResponseAsync(context, ex); return null; } catch (Exception ex) { // catches JSON deserialization exceptions if (!await HandleDeserializationErrorAsync(context, _next, ex)) @@ -522,14 +526,16 @@ 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) { - if (result.Errors?.Any(e => e is HttpMethodValidationError) == true) - statusCode = HttpStatusCode.MethodNotAllowed; - else if (_options.ValidationErrorsReturnBadRequest) - statusCode = HttpStatusCode.BadRequest; + // always return 405 Method Not Allowed when applicable, as this is a transport problem, not really a validation error, + // 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) { + await WriteJsonResponseAsync(context, HttpStatusCode.MethodNotAllowed, result); + return; + } } - await WriteJsonResponseAsync(context, statusCode, result); + await WriteJsonResponseAsync(context, result); } /// @@ -655,24 +661,236 @@ 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), + _applicationJsonMediaType, + MediaTypeHeaderValueMs.Parse(CONTENTTYPE_GRAPHQLJSON), // deprecated + }; + /// /// Selects a response content type string based on the . - /// Defaults to . Override this value for compatibility - /// with non-conforming GraphQL clients. + /// The default implementation attempts to match the content-type requested by the + /// client through the 'Accept' HTTP header to the default content type specified + /// within . + /// If matched, the specified content-type is returned; if not, supported + /// content-types are tested ("application/json", "application/graphql+json", and + /// "application/graphql-response+json") to see if they match the 'Accept' header. ///

/// Note that by default, the response will be written as UTF-8 encoded JSON, regardless - /// of the content-type value here. For more complex behavior patterns, override + /// of the content-type value here, and this method's default implementation assumes as much. + /// For more complex behavior patterns, override /// . ///
- protected virtual string SelectResponseContentType(HttpContext context) - => CONTENTTYPE_GRAPHQLJSON; + 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 == 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 quality, then specific types, then to types with wildcards + var sortedAcceptHeaders = acceptHeaders + .OrderByDescending(x => x.Quality ?? 1.0) + .ThenBy(x => x.MatchesAllTypes ? 4 : x.MatchesAllSubTypes ? 3 : x.MatchesAllSubTypesWithoutSuffix ? 2 : 1); + foreach (var acceptHeader in sortedAcceptHeaders) { + 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.DefaultResponseContentType; + } + + /// + /// 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); + + /// + /// 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; + + // 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); + } + + // 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; + } + + // 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 + + // The ASP.NET Core 6.0 source contains logic that is not suitable -- it will consider + // "application/graphql-response+json" to match an 'Accept' header of "application/json", + // which can break client applications. + + /* + * Copyright (c) .NET Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use + * these files except in compliance with the License. You may obtain a copy of the + * License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + */ + + static bool IsSubsetOf(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs otherMediaType) + { + // "text/plain" is a subset of "text/plain", "text/*" and "*/*". "*/*" is a subset only of "*/*". + return MatchesType(mediaType, otherMediaType) && + MatchesSubtype(mediaType, otherMediaType) && + MatchesParameters(mediaType, otherMediaType); + } + + static bool MatchesType(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs set) + { + return set.MatchesAllTypes || + set.Type.Equals(mediaType.Type, StringComparison.OrdinalIgnoreCase); + } + + static bool MatchesSubtype(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs set) + { + if (set.MatchesAllSubTypes) { + return true; + } + if (set.Suffix.HasValue) { + if (mediaType.Suffix.HasValue) { + return MatchesSubtypeWithoutSuffix(mediaType, set) && MatchesSubtypeSuffix(mediaType, set); + } else { + return false; + } + } else { + return set.SubType.Equals(mediaType.SubType, StringComparison.OrdinalIgnoreCase); + } + } + + static bool MatchesSubtypeWithoutSuffix(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs set) + { + return set.MatchesAllSubTypesWithoutSuffix || + set.SubTypeWithoutSuffix.Equals(mediaType.SubTypeWithoutSuffix, StringComparison.OrdinalIgnoreCase); + } + + static bool MatchesParameters(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs set) + { + if (set.Parameters.Count != 0) { + // Make sure all parameters in the potential superset are included locally. Fine to have additional + // parameters locally; they make this one more specific. + foreach (var parameter in set.Parameters) { + if (parameter.Name.Equals("*", StringComparison.OrdinalIgnoreCase)) { + // A parameter named "*" has no effect on media type matching, as it is only used as an indication + // that the entire media type string should be treated as a wildcard. + continue; + } + + if (parameter.Name.Equals("q", StringComparison.OrdinalIgnoreCase)) { + // "q" and later parameters are not involved in media type matching. Quoting the RFC: The first + // "q" parameter (if any) separates the media-range parameter(s) from the accept-params. + break; + } + + var localParameter = Microsoft.Net.Http.Headers.NameValueHeaderValue.Find(mediaType.Parameters, parameter.Name); + if (localParameter == null) { + // Not found. + return false; + } + + if (!StringSegment.Equals(parameter.Value, localParameter.Value, StringComparison.OrdinalIgnoreCase)) { + return false; + } + } + } + return true; + } + + static bool MatchesSubtypeSuffix(MediaTypeHeaderValueMs mediaType, MediaTypeHeaderValueMs set) + // We don't have support for wildcards on suffixes alone (e.g., "application/entity+*") + // because there's no clear use case for it. + => set.Suffix.Equals(mediaType.Suffix, StringComparison.OrdinalIgnoreCase); + + // --- end of ASP.NET Core 2.1 copied functions --- + } + + /// + /// 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. + /// 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); @@ -782,19 +1000,19 @@ await webSocket.CloseAsync( /// Writes an access denied message to the output with status code 401 Unauthorized when the user is not authenticated. ///
protected virtual Task HandleNotAuthenticatedAsync(HttpContext context, RequestDelegate next) - => WriteErrorResponseAsync(context, HttpStatusCode.Unauthorized, new AccessDeniedError("schema")); + => WriteErrorResponseAsync(context, new AccessDeniedError("schema") { PreferredStatusCode = HttpStatusCode.Unauthorized }); /// /// Writes an access denied message to the output with status code 403 Forbidden when the user fails the role checks. /// protected virtual Task HandleNotAuthorizedRoleAsync(HttpContext context, RequestDelegate next) - => WriteErrorResponseAsync(context, HttpStatusCode.Forbidden, new AccessDeniedError("schema") { RolesRequired = _options.AuthorizedRoles }); + => WriteErrorResponseAsync(context, new AccessDeniedError("schema") { RolesRequired = _options.AuthorizedRoles }); /// /// Writes an access denied message to the output with status code 403 Forbidden when the user fails the policy check. /// protected virtual Task HandleNotAuthorizedPolicyAsync(HttpContext context, RequestDelegate next, AuthorizationResult authorizationResult) - => WriteErrorResponseAsync(context, HttpStatusCode.Forbidden, new AccessDeniedError("schema") { PolicyRequired = _options.AuthorizedPolicy, PolicyAuthorizationResult = authorizationResult }); + => WriteErrorResponseAsync(context, new AccessDeniedError("schema") { PolicyRequired = _options.AuthorizedPolicy, PolicyAuthorizationResult = authorizationResult }); /// /// Writes a '400 JSON body text could not be parsed.' message to the output. @@ -831,7 +1049,7 @@ protected virtual Task HandleWebSocketSubProtocolNotSupportedAsync(HttpContext c /// Writes a '415 Invalid Content-Type header: could not be parsed.' message to the output. /// protected virtual Task HandleContentTypeCouldNotBeParsedErrorAsync(HttpContext context, RequestDelegate next) - => WriteErrorResponseAsync(context, HttpStatusCode.UnsupportedMediaType, new InvalidContentTypeError($"value '{context.Request.ContentType}' could not be parsed.")); + => WriteErrorResponseAsync(context, new InvalidContentTypeError($"value '{context.Request.ContentType}' could not be parsed.")); /// /// Writes a '415 Invalid Content-Type header: non-supported media type.' message to the output. @@ -839,7 +1057,6 @@ protected virtual Task HandleContentTypeCouldNotBeParsedErrorAsync(HttpContext c protected virtual Task HandleInvalidContentTypeErrorAsync(HttpContext context, RequestDelegate next) => WriteErrorResponseAsync( context, - HttpStatusCode.UnsupportedMediaType, _options.ReadFormOnPost ? new InvalidContentTypeError($"non-supported media type '{context.Request.ContentType}'. Must be '{MEDIATYPE_JSON}', '{MEDIATYPE_GRAPHQL}' or a form body.") : new InvalidContentTypeError($"non-supported media type '{context.Request.ContentType}'. Must be '{MEDIATYPE_JSON}' or '{MEDIATYPE_GRAPHQL}'.") diff --git a/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs b/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs index 9b16e2c..640ec11 100644 --- a/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs +++ b/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs @@ -1,3 +1,5 @@ +using MediaTypeHeaderValueMs = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; + namespace GraphQL.AspNetCore3; /// @@ -34,13 +36,23 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions public bool ExecuteBatchedRequestsInParallel { get; set; } = true; /// - /// When enabled, GraphQL requests with validation errors - /// have the HTTP status code set to 400 Bad Request. - /// GraphQL requests with execution errors are unaffected. + /// 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, 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 an 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. @@ -139,4 +151,21 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions /// Returns an options class for WebSocket connections. /// public GraphQLWebSocketOptions WebSockets { get; set; } = new(); + + private MediaTypeHeaderValueMs _defaultResponseContentType = MediaTypeHeaderValueMs.Parse(GraphQLHttpMiddleware.CONTENTTYPE_GRAPHQLRESPONSEJSON); + + /// + /// The Content-Type to use for GraphQL responses, if it matches the 'Accept' + /// HTTP request header. Defaults to "application/graphql-response+json; charset=utf-8". + /// + public MediaTypeHeaderValueMs DefaultResponseContentType + { + get => _defaultResponseContentType; + set { + _defaultResponseContentType = value; + DefaultResponseContentTypeString = value.ToString(); + } + } + + internal string DefaultResponseContentTypeString { get; set; } = GraphQLHttpMiddleware.CONTENTTYPE_GRAPHQLRESPONSEJSON; } diff --git a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt index c3bf427..ecba883 100644 --- a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt +++ b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt @@ -148,10 +148,11 @@ namespace GraphQL.AspNetCore3 "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.AspNetCore3.IAuthorizationOptions @@ -163,6 +164,7 @@ namespace GraphQL.AspNetCore3 public System.Collections.Generic.List AuthorizedRoles { get; set; } public bool CsrfProtectionEnabled { get; set; } public System.Collections.Generic.List CsrfProtectionHeaders { get; set; } + public Microsoft.Net.Http.Headers.MediaTypeHeaderValue DefaultResponseContentType { get; set; } public bool EnableBatchedRequests { get; set; } public bool ExecuteBatchedRequestsInParallel { get; set; } public bool HandleGet { get; set; } @@ -174,7 +176,7 @@ namespace GraphQL.AspNetCore3 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.AspNetCore3.WebSockets.GraphQLWebSocketOptions WebSockets { get; set; } } public class GraphQLHttpMiddleware : GraphQL.AspNetCore3.GraphQLHttpMiddleware @@ -218,12 +220,13 @@ namespace GraphQL.AspNetCore3 } namespace GraphQL.AspNetCore3.Errors { - public class AccessDeniedError : GraphQL.Validation.ValidationError + public class AccessDeniedError : GraphQL.Validation.ValidationError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode { public AccessDeniedError(string resource) { } public AccessDeniedError(string resource, GraphQLParser.ROM originalQuery, params GraphQLParser.AST.ASTNode[] nodes) { } public Microsoft.AspNetCore.Authorization.AuthorizationResult? PolicyAuthorizationResult { get; set; } public string? PolicyRequired { get; set; } + public System.Net.HttpStatusCode PreferredStatusCode { get; set; } public System.Collections.Generic.List? RolesRequired { get; set; } } public class BatchedRequestsNotSupportedError : GraphQL.Execution.RequestError @@ -238,25 +241,27 @@ namespace GraphQL.AspNetCore3.Errors public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode { public FileCountExceededError() { } - public System.Net.HttpStatusCode PreferredStatusCode { get; } + public System.Net.HttpStatusCode PreferredStatusCode { get; set; } } public class FileSizeExceededError : GraphQL.Execution.RequestError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode { public FileSizeExceededError() { } - public System.Net.HttpStatusCode PreferredStatusCode { get; } + public System.Net.HttpStatusCode PreferredStatusCode { get; set; } } - public class HttpMethodValidationError : GraphQL.Validation.ValidationError + public class HttpMethodValidationError : GraphQL.Validation.ValidationError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode { public HttpMethodValidationError(GraphQLParser.ROM originalQuery, GraphQLParser.AST.ASTNode node, string message) { } + public System.Net.HttpStatusCode PreferredStatusCode { get; set; } } public interface IHasPreferredStatusCode { System.Net.HttpStatusCode PreferredStatusCode { get; } } - public class InvalidContentTypeError : GraphQL.Execution.RequestError + public class InvalidContentTypeError : GraphQL.Execution.RequestError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode { public InvalidContentTypeError() { } public InvalidContentTypeError(string message) { } + public System.Net.HttpStatusCode PreferredStatusCode { get; set; } } public class InvalidMapError : GraphQL.Execution.RequestError { diff --git a/src/Tests/AuthorizationTests.cs b/src/Tests/AuthorizationTests.cs index 99e286b..082bc55 100644 --- a/src/Tests/AuthorizationTests.cs +++ b/src/Tests/AuthorizationTests.cs @@ -742,7 +742,7 @@ public async Task EndToEnd(bool authenticated) using var client = server.CreateClient(); using var response = await client.GetAsync("/graphql?query={ parent { child } }"); - response.StatusCode.ShouldBe(authenticated ? System.Net.HttpStatusCode.OK : System.Net.HttpStatusCode.BadRequest); + response.StatusCode.ShouldBe(authenticated ? System.Net.HttpStatusCode.OK : System.Net.HttpStatusCode.Unauthorized); var actual = await response.Content.ReadAsStringAsync(); if (authenticated) diff --git a/src/Tests/Middleware/GetTests.cs b/src/Tests/Middleware/GetTests.cs index e387663..d451ec4 100644 --- a/src/Tests/Middleware/GetTests.cs +++ b/src/Tests/Middleware/GetTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using GraphQL.PersistedDocuments; namespace Tests.Middleware; @@ -151,14 +152,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/src/Tests/Middleware/PostTests.cs b/src/Tests/Middleware/PostTests.cs index 909508a..133764a 100644 --- a/src/Tests/Middleware/PostTests.cs +++ b/src/Tests/Middleware/PostTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Http.Headers; +using System.Net.Mime; using GraphQL.PersistedDocuments; namespace Tests.Middleware; @@ -568,13 +570,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/src/Tests/ShouldlyExtensions.cs b/src/Tests/ShouldlyExtensions.cs index b22bc89..ad0a906 100644 --- a/src/Tests/ShouldlyExtensions.cs +++ b/src/Tests/ShouldlyExtensions.cs @@ -2,6 +2,7 @@ namespace Tests; +[ShouldlyMethods] internal static class ShouldlyExtensions { public static Task ShouldBeAsync(this HttpResponseMessage message, bool badRequest, string expectedResponse) @@ -10,11 +11,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); - message.Content.Headers.ContentType?.MediaType.ShouldBe("application/graphql+json"); - message.Content.Headers.ContentType?.CharSet.ShouldBe("utf-8"); + (message.Content.Headers.ContentType?.ToString()).ShouldBe(contentType); var actualResponse = await message.Content.ReadAsStringAsync(); actualResponse.ShouldBe(expectedResponse); }