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