Skip to content

Commit bf0cd69

Browse files
authored
Status codes per GraphQL over HTTP spec (#1142)
1 parent 39aef15 commit bf0cd69

File tree

10 files changed

+171
-74
lines changed

10 files changed

+171
-74
lines changed

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -720,9 +720,11 @@ methods allowing for different options for each configured endpoint.
720720
| `ReadFormOnPost` | Enables parsing of form data for POST requests (may have security implications). | False |
721721
| `ReadQueryStringOnPost` | Enables parsing the query string on POST requests. | True |
722722
| `ReadVariablesFromQueryString` | Enables reading variables from the query string. | True |
723-
| `ValidationErrorsReturnBadRequest` | When enabled, GraphQL requests with validation errors have the HTTP status code set to 400 Bad Request. | True |
723+
| `ValidationErrorsReturnBadRequest` | When enabled, GraphQL requests with validation errors have the HTTP status code set to 400 Bad Request. | Automatic[^1] |
724724
| `WebSockets` | Returns a set of configuration properties for WebSocket connections. | |
725725

726+
[^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.
727+
726728
#### GraphQLWebSocketOptions
727729

728730
| Property | Description | Default value |

docs/migration/migration8.md

+9
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@
66
types for the file by using the new `[MediaType]` attribute on the argument or input object field.
77
- Cross-site request forgery (CSRF) protection has been added for both GET and POST requests,
88
enabled by default.
9+
- Status codes for validation errors are now, by default, determined by the response content type,
10+
and for authentication errors may return a 401 or 403 status code. These changes are purusant
11+
to the [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md).
12+
See the breaking changes section below for more information.
913

1014
## Breaking changes
1115

16+
- `GraphQLHttpMiddlewareOptions.ValidationErrorsReturnBadRequest` is now a nullable boolean where
17+
`null` means "use the default behavior". The default behavior is to return a 200 status code
18+
when the response content type is `application/json` and a 400 status code otherwise. The
19+
default value for this in v7 was `true`; set this option to retain the v7 behavior.
1220
- The validation rules' signatures have changed slightly due to the underlying changes to the
1321
GraphQL.NET library. Please see the GraphQL.NET v8 migration document for more information.
1422
- The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration
@@ -25,6 +33,7 @@
2533
a 400 status code (e.g. the execution of the document has not yet begun), and (2) all errors
2634
in the response prefer the same status code. For practical purposes, this means that the included
2735
errors triggered by the authorization validation rule will now return 401 or 403 when appropriate.
36+
- The `SelectResponseContentType` method now returns a `MediaTypeHeaderValue` instead of a string.
2837

2938
## Other changes
3039

src/Transports.AspNetCore/GraphQLHttpMiddleware.cs

+94-49
Original file line numberDiff line numberDiff line change
@@ -583,29 +583,18 @@ protected virtual async Task HandleRequestAsync(
583583
// Normal execution with single graphql request
584584
var userContext = await BuildUserContextAsync(context, null);
585585
var result = await ExecuteRequestAsync(context, gqlRequest, context.RequestServices, userContext);
586-
HttpStatusCode statusCode = HttpStatusCode.OK;
587586
// when the request fails validation (this logic does not apply to execution errors)
588587
if (!result.Executed)
589588
{
590589
// always return 405 Method Not Allowed when applicable, as this is a transport problem, not really a validation error,
591590
// even though it occurs during validation (because the query text must be parsed to know if the request is a query or a mutation)
592591
if (result.Errors?.Any(e => e is HttpMethodValidationError) == true)
593592
{
594-
statusCode = HttpStatusCode.MethodNotAllowed;
595-
}
596-
// otherwise use 4xx error codes when configured to do so
597-
else if (_options.ValidationErrorsReturnBadRequest)
598-
{
599-
statusCode = HttpStatusCode.BadRequest;
600-
// if all errors being returned prefer the same status code, use that
601-
if (result.Errors?.Count > 0 && result.Errors[0] is IHasPreferredStatusCode initialError)
602-
{
603-
if (result.Errors.All(e => e is IHasPreferredStatusCode e2 && e2.PreferredStatusCode == initialError.PreferredStatusCode))
604-
statusCode = initialError.PreferredStatusCode;
605-
}
593+
await WriteJsonResponseAsync(context, HttpStatusCode.MethodNotAllowed, result);
594+
return;
606595
}
607596
}
608-
await WriteJsonResponseAsync(context, statusCode, result);
597+
await WriteJsonResponseAsync(context, result);
609598
}
610599

611600
/// <summary>
@@ -750,10 +739,11 @@ protected virtual async Task<ExecutionResult> ExecuteRequestAsync(HttpContext co
750739
ValueTask<IDictionary<string, object?>?> IUserContextBuilder.BuildUserContextAsync(HttpContext context, object? payload)
751740
=> BuildUserContextAsync(context, payload);
752741

742+
private static readonly MediaTypeHeaderValueMs _applicationJsonMediaType = MediaTypeHeaderValueMs.Parse(CONTENTTYPE_JSON);
753743
private static readonly MediaTypeHeaderValueMs[] _validMediaTypes = new[]
754744
{
755745
MediaTypeHeaderValueMs.Parse(CONTENTTYPE_GRAPHQLRESPONSEJSON),
756-
MediaTypeHeaderValueMs.Parse(CONTENTTYPE_JSON),
746+
_applicationJsonMediaType,
757747
MediaTypeHeaderValueMs.Parse(CONTENTTYPE_GRAPHQLJSON), // deprecated
758748
};
759749

@@ -771,62 +761,87 @@ protected virtual async Task<ExecutionResult> ExecuteRequestAsync(HttpContext co
771761
/// For more complex behavior patterns, override
772762
/// <see cref="WriteJsonResponseAsync{TResult}(HttpContext, HttpStatusCode, TResult)"/>.
773763
/// </summary>
774-
protected virtual string SelectResponseContentType(HttpContext context)
764+
protected virtual MediaTypeHeaderValueMs SelectResponseContentType(HttpContext context)
775765
{
776766
// pull the Accept header, which may contain multiple content types
777767
var acceptHeaders = context.Request.Headers.ContainsKey(Microsoft.Net.Http.Headers.HeaderNames.Accept)
778768
? context.Request.GetTypedHeaders().Accept
779769
: Array.Empty<MediaTypeHeaderValueMs>();
780770

781-
if (acceptHeaders.Count > 0)
771+
if (acceptHeaders.Count == 1)
772+
{
773+
var response = IsSupportedMediaType(acceptHeaders[0]);
774+
if (response != null)
775+
return response;
776+
}
777+
else if (acceptHeaders.Count > 0)
782778
{
783779
// enumerate through each content type and see if it matches a supported content type
784780
// give priority to specific types, then to types with wildcards
785781
foreach (var acceptHeader in acceptHeaders.OrderBy(x => x.MatchesAllTypes ? 4 : x.MatchesAllSubTypes ? 3 : x.MatchesAllSubTypesWithoutSuffix ? 2 : 1))
786782
{
787-
var response = CheckForMatch(acceptHeader);
783+
var response = IsSupportedMediaType(acceptHeader);
788784
if (response != null)
789785
return response;
790786
}
791787
}
792788

793789
// return the default content type if no match is found, or if there is no 'Accept' header
794-
return _options.DefaultResponseContentTypeString;
790+
return _options.DefaultResponseContentType;
791+
}
795792

796-
string? CheckForMatch(MediaTypeHeaderValueMs acceptHeader)
797-
{
798-
// strip quotes from charset
799-
if (acceptHeader.Charset.Length > 0 && acceptHeader.Charset[0] == '\"' && acceptHeader.Charset[acceptHeader.Charset.Length - 1] == '\"')
800-
{
801-
acceptHeader.Charset = acceptHeader.Charset.Substring(1, acceptHeader.Charset.Length - 2);
802-
}
793+
/// <summary>
794+
/// Checks to see if the specified <see cref="MediaTypeHeaderValueMs"/> matches any of the supported content types
795+
/// by this middleware. If a match is found, the matching content type is returned; otherwise, <see langword="null"/>.
796+
/// Prioritizes <see cref="GraphQLHttpMiddlewareOptions.DefaultResponseContentType"/>, then
797+
/// <c>application/graphql-response+json</c>, then <c>application/json</c>.
798+
/// </summary>
799+
private MediaTypeHeaderValueMs? IsSupportedMediaType(MediaTypeHeaderValueMs acceptHeader)
800+
=> IsSupportedMediaType(acceptHeader, _options.DefaultResponseContentType, _validMediaTypes);
803801

804-
// check if this matches the default content type header
805-
if (IsSubsetOf(_options.DefaultResponseContentType, acceptHeader))
806-
return _options.DefaultResponseContentTypeString;
802+
/// <summary>
803+
/// Checks to see if the specified <see cref="MediaTypeHeaderValueMs"/> matches any of the supported content types
804+
/// by this middleware. If a match is found, the matching content type is returned; otherwise, <see langword="null"/>.
805+
/// Prioritizes <see cref="GraphQLHttpMiddlewareOptions.DefaultResponseContentType"/>, then
806+
/// <c>application/graphql-response+json</c>, then <c>application/json</c>.
807+
/// </summary>
808+
private static MediaTypeHeaderValueMs? IsSupportedMediaType(MediaTypeHeaderValueMs acceptHeader, MediaTypeHeaderValueMs preferredContentType, MediaTypeHeaderValueMs[] allowedContentTypes)
809+
{
810+
// speeds check in WriteJsonResponseAsync
811+
if (acceptHeader == preferredContentType)
812+
return preferredContentType;
807813

808-
// if the default content type header does not contain a charset, test with utf-8 as the charset
809-
if (_options.DefaultResponseContentType.Charset.Length == 0)
810-
{
811-
var contentType2 = _options.DefaultResponseContentType.Copy();
812-
contentType2.Charset = "utf-8";
813-
if (IsSubsetOf(contentType2, acceptHeader))
814-
return contentType2.ToString();
815-
}
814+
// strip quotes from charset
815+
if (acceptHeader.Charset.Length > 0 && acceptHeader.Charset[0] == '\"' && acceptHeader.Charset[acceptHeader.Charset.Length - 1] == '\"')
816+
{
817+
acceptHeader.Charset = acceptHeader.Charset.Substring(1, acceptHeader.Charset.Length - 2);
818+
}
816819

817-
// loop through the other supported media types, attempting to find a match
818-
for (int j = 0; j < _validMediaTypes.Length; j++)
819-
{
820-
var mediaType = _validMediaTypes[j];
821-
if (IsSubsetOf(mediaType, acceptHeader))
822-
// when a match is found, return the match
823-
return mediaType.ToString();
824-
}
820+
// check if this matches the default content type header
821+
if (IsSubsetOf(preferredContentType, acceptHeader))
822+
return preferredContentType;
823+
824+
// if the default content type header does not contain a charset, test with utf-8 as the charset
825+
if (preferredContentType.Charset.Length == 0)
826+
{
827+
var contentType2 = preferredContentType.Copy();
828+
contentType2.Charset = "utf-8";
829+
if (IsSubsetOf(contentType2, acceptHeader))
830+
return contentType2;
831+
}
825832

826-
// no match
827-
return null;
833+
// loop through the other supported media types, attempting to find a match
834+
for (int j = 0; j < allowedContentTypes.Length; j++)
835+
{
836+
var mediaType = allowedContentTypes[j];
837+
if (IsSubsetOf(mediaType, acceptHeader))
838+
// when a match is found, return the match
839+
return mediaType;
828840
}
829841

842+
// no match
843+
return null;
844+
830845
// --- note: the below functions were copied from ASP.NET Core 2.1 source ---
831846
// see https://github.com/dotnet/aspnetcore/blob/v2.1.33/src/Http/Headers/src/MediaTypeHeaderValue.cs
832847

@@ -940,11 +955,41 @@ static bool MatchesSubtypeSuffix(MediaTypeHeaderValueMs mediaType, MediaTypeHead
940955
}
941956

942957
/// <summary>
943-
/// Writes the specified object (usually a GraphQL response represented as an instance of <see cref="ExecutionResult"/>) as JSON to the HTTP response stream.
958+
/// Writes the specified <see cref="ExecutionResult"/> as JSON to the HTTP response stream,
959+
/// selecting the proper content type and status code based on the request Accept header and response.
960+
/// </summary>
961+
protected virtual Task WriteJsonResponseAsync(HttpContext context, ExecutionResult result)
962+
{
963+
var contentType = SelectResponseContentType(context);
964+
context.Response.ContentType = contentType == _options.DefaultResponseContentType ? _options.DefaultResponseContentTypeString : contentType.ToString();
965+
context.Response.StatusCode = (int)HttpStatusCode.OK;
966+
if (result.Executed == false)
967+
{
968+
var useBadRequest = _options.ValidationErrorsReturnBadRequest ?? IsSupportedMediaType(contentType, _applicationJsonMediaType, Array.Empty<MediaTypeHeaderValueMs>()) == null;
969+
if (useBadRequest)
970+
{
971+
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
972+
973+
// if all errors being returned prefer the same status code, use that
974+
if (result.Errors?.Count > 0 && result.Errors[0] is IHasPreferredStatusCode initialError)
975+
{
976+
if (result.Errors.All(e => e is IHasPreferredStatusCode e2 && e2.PreferredStatusCode == initialError.PreferredStatusCode))
977+
context.Response.StatusCode = (int)initialError.PreferredStatusCode;
978+
}
979+
}
980+
}
981+
982+
return _serializer.WriteAsync(context.Response.Body, result, context.RequestAborted);
983+
}
984+
985+
/// <summary>
986+
/// Writes the specified object (usually a GraphQL response represented as an instance of <see cref="ExecutionResult"/>)
987+
/// as JSON to the HTTP response stream, using the specified status code.
944988
/// </summary>
945989
protected virtual Task WriteJsonResponseAsync<TResult>(HttpContext context, HttpStatusCode httpStatusCode, TResult result)
946990
{
947-
context.Response.ContentType = SelectResponseContentType(context);
991+
var contentType = SelectResponseContentType(context);
992+
context.Response.ContentType = contentType == _options.DefaultResponseContentType ? _options.DefaultResponseContentTypeString : contentType.ToString();
948993
context.Response.StatusCode = (int)httpStatusCode;
949994

950995
return _serializer.WriteAsync(context.Response.Body, result, context.RequestAborted);

src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs

+13-3
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,22 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
4444

4545
/// <summary>
4646
/// When enabled, GraphQL requests with validation errors have the HTTP status code
47-
/// set to 400 Bad Request or the error status code dictated by the error.
48-
/// GraphQL requests with execution errors are unaffected.
47+
/// set to 400 Bad Request or the error status code dictated by the error, while
48+
/// setting this to <c>false</c> will use a 200 status code for all responses.
49+
/// <br/><br/>
50+
/// GraphQL requests with execution errors are unaffected and return a 200 status code.
51+
/// <br/><br/>
52+
/// Transport errors, such as a transport-level authentication failure, are not affected
53+
/// and return a error-specific status code, such as 405 Method Not Allowed if a mutation
54+
/// is attempted over a HTTP GET connection.
4955
/// <br/><br/>
5056
/// Does not apply to batched or WebSocket requests.
57+
/// <br/><br/>
58+
/// Settings this to <see langword="null"/> will use a 200 status code for
59+
/// <c>application/json</c> responses and use a 4xx status code for
60+
/// <c>application/graphql-response+json</c> and other responses.
5161
/// </summary>
52-
public bool ValidationErrorsReturnBadRequest { get; set; } = true;
62+
public bool? ValidationErrorsReturnBadRequest { get; set; }
5363

5464
/// <summary>
5565
/// Enables parsing the query string on POST requests.

tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt

+3-2
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,11 @@ namespace GraphQL.Server.Transports.AspNetCore
116116
"SingleRequest",
117117
"BatchRequest"})]
118118
protected virtual System.Threading.Tasks.Task<System.ValueTuple<GraphQL.Transport.GraphQLRequest?, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?>?>?> ReadPostContentAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) { }
119-
protected virtual string SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { }
119+
protected virtual Microsoft.Net.Http.Headers.MediaTypeHeaderValue SelectResponseContentType(Microsoft.AspNetCore.Http.HttpContext context) { }
120120
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionError executionError) { }
121121
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, GraphQL.ExecutionError executionError) { }
122122
protected virtual System.Threading.Tasks.Task WriteErrorResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, string errorMessage) { }
123+
protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.ExecutionResult result) { }
123124
protected virtual System.Threading.Tasks.Task WriteJsonResponseAsync<TResult>(Microsoft.AspNetCore.Http.HttpContext context, System.Net.HttpStatusCode httpStatusCode, TResult result) { }
124125
}
125126
public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions
@@ -143,7 +144,7 @@ namespace GraphQL.Server.Transports.AspNetCore
143144
public bool ReadFormOnPost { get; set; }
144145
public bool ReadQueryStringOnPost { get; set; }
145146
public bool ReadVariablesFromQueryString { get; set; }
146-
public bool ValidationErrorsReturnBadRequest { get; set; }
147+
public bool? ValidationErrorsReturnBadRequest { get; set; }
147148
public GraphQL.Server.Transports.AspNetCore.WebSockets.GraphQLWebSocketOptions WebSockets { get; set; }
148149
}
149150
public class GraphQLHttpMiddleware<TSchema> : GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddleware

0 commit comments

Comments
 (0)