Skip to content

Commit 39aef15

Browse files
authored
Allow any validation error to prefer a status code over 400 (#1141)
1 parent 544a5e2 commit 39aef15

File tree

11 files changed

+175
-11
lines changed

11 files changed

+175
-11
lines changed

docs/migration/migration8.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
the `CsrfProtectionHeaders` property on the same class. See the readme for more details.
2121
- Form POST requests are disabled by default; to enable them, set the `ReadFormOnPost` setting
2222
to `true`.
23+
- Validation errors such as authentication errors may now be returned with a 'preferred' status
24+
code instead of a 400 status code. This occurs when (1) the response would otherwise contain
25+
a 400 status code (e.g. the execution of the document has not yet begun), and (2) all errors
26+
in the response prefer the same status code. For practical purposes, this means that the included
27+
errors triggered by the authorization validation rule will now return 401 or 403 when appropriate.
2328

2429
## Other changes
2530

src/Transports.AspNetCore/Errors/HttpMethodValidationError.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors;
44
/// Represents a validation error indicating that the requested operation is not valid
55
/// for the type of HTTP request.
66
/// </summary>
7-
public class HttpMethodValidationError : ValidationError
7+
public class HttpMethodValidationError : ValidationError, IHasPreferredStatusCode
88
{
99
/// <inheritdoc cref="HttpMethodValidationError"/>
1010
public HttpMethodValidationError(GraphQLParser.ROM originalQuery, ASTNode node, string message)
1111
: base(originalQuery, null!, message, node)
1212
{
1313
}
14+
15+
/// <inheritdoc/>
16+
public HttpStatusCode PreferredStatusCode { get; set; } = HttpStatusCode.MethodNotAllowed;
1417
}

src/Transports.AspNetCore/GraphQLHttpMiddleware.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,12 +584,26 @@ protected virtual async Task HandleRequestAsync(
584584
var userContext = await BuildUserContextAsync(context, null);
585585
var result = await ExecuteRequestAsync(context, gqlRequest, context.RequestServices, userContext);
586586
HttpStatusCode statusCode = HttpStatusCode.OK;
587+
// when the request fails validation (this logic does not apply to execution errors)
587588
if (!result.Executed)
588589
{
590+
// always return 405 Method Not Allowed when applicable, as this is a transport problem, not really a validation error,
591+
// even though it occurs during validation (because the query text must be parsed to know if the request is a query or a mutation)
589592
if (result.Errors?.Any(e => e is HttpMethodValidationError) == true)
593+
{
590594
statusCode = HttpStatusCode.MethodNotAllowed;
595+
}
596+
// otherwise use 4xx error codes when configured to do so
591597
else if (_options.ValidationErrorsReturnBadRequest)
598+
{
592599
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+
}
606+
}
593607
}
594608
await WriteJsonResponseAsync(context, statusCode, result);
595609
}

src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
4343
public bool ExecuteBatchedRequestsInParallel { get; set; } = true;
4444

4545
/// <summary>
46-
/// When enabled, GraphQL requests with validation errors
47-
/// have the HTTP status code set to 400 Bad Request.
46+
/// 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.
4848
/// GraphQL requests with execution errors are unaffected.
4949
/// <br/><br/>
5050
/// Does not apply to batched or WebSocket requests.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,10 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors
220220
public FileSizeExceededError() { }
221221
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
222222
}
223-
public class HttpMethodValidationError : GraphQL.Validation.ValidationError
223+
public class HttpMethodValidationError : GraphQL.Validation.ValidationError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode
224224
{
225225
public HttpMethodValidationError(GraphQLParser.ROM originalQuery, GraphQLParser.AST.ASTNode node, string message) { }
226+
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
226227
}
227228
public interface IHasPreferredStatusCode
228229
{

tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,10 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors
238238
public FileSizeExceededError() { }
239239
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
240240
}
241-
public class HttpMethodValidationError : GraphQL.Validation.ValidationError
241+
public class HttpMethodValidationError : GraphQL.Validation.ValidationError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode
242242
{
243243
public HttpMethodValidationError(GraphQLParser.ROM originalQuery, GraphQLParser.AST.ASTNode node, string message) { }
244+
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
244245
}
245246
public interface IHasPreferredStatusCode
246247
{

tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,10 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors
220220
public FileSizeExceededError() { }
221221
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
222222
}
223-
public class HttpMethodValidationError : GraphQL.Validation.ValidationError
223+
public class HttpMethodValidationError : GraphQL.Validation.ValidationError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode
224224
{
225225
public HttpMethodValidationError(GraphQLParser.ROM originalQuery, GraphQLParser.AST.ASTNode node, string message) { }
226+
public System.Net.HttpStatusCode PreferredStatusCode { get; set; }
226227
}
227228
public interface IHasPreferredStatusCode
228229
{

tests/Samples.Authorization.Tests/EndToEndTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ public Task GraphQLGet_Success()
2121

2222
[Fact]
2323
public Task GraphQLGet_AccessDenied()
24-
=> new ServerTests<Program>().VerifyGraphQLGetAsync("/graphql", ACCESS_DENIED_QUERY, ACCESS_DENIED_RESPONSE, HttpStatusCode.BadRequest);
24+
=> new ServerTests<Program>().VerifyGraphQLGetAsync("/graphql", ACCESS_DENIED_QUERY, ACCESS_DENIED_RESPONSE, HttpStatusCode.Unauthorized);
2525

2626
[Fact]
2727
public Task GraphQLPost_Success()
2828
=> new ServerTests<Program>().VerifyGraphQLPostAsync("/graphql", SUCCESS_QUERY, SUCCESS_RESPONSE);
2929

3030
[Fact]
3131
public Task GraphQPost_AccessDenied()
32-
=> new ServerTests<Program>().VerifyGraphQLPostAsync("/graphql", ACCESS_DENIED_QUERY, ACCESS_DENIED_RESPONSE, HttpStatusCode.BadRequest);
32+
=> new ServerTests<Program>().VerifyGraphQLPostAsync("/graphql", ACCESS_DENIED_QUERY, ACCESS_DENIED_RESPONSE, HttpStatusCode.Unauthorized);
3333

3434
[Fact]
3535
public Task GraphQLWebSocket_Success()

tests/Transports.AspNetCore.Tests/AuthorizationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,7 @@ public async Task EndToEnd(bool authenticated)
765765

766766
using var client = server.CreateClient();
767767
using var response = await client.GetAsync("/graphql?query={ parent { child } }");
768-
response.StatusCode.ShouldBe(authenticated ? System.Net.HttpStatusCode.OK : System.Net.HttpStatusCode.BadRequest);
768+
response.StatusCode.ShouldBe(authenticated ? System.Net.HttpStatusCode.OK : System.Net.HttpStatusCode.Unauthorized);
769769
var actual = await response.Content.ReadAsStringAsync();
770770

771771
if (authenticated)

tests/Transports.AspNetCore.Tests/Middleware/GetTests.cs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using System.Net;
2+
using GraphQL.Server.Transports.AspNetCore.Errors;
3+
using GraphQL.Validation;
24

35
namespace Tests.Middleware;
46

57
public class GetTests : IDisposable
68
{
79
private GraphQLHttpMiddlewareOptions _options = null!;
810
private GraphQLHttpMiddlewareOptions _options2 = null!;
9-
private readonly Action<ExecutionOptions> _configureExecution = _ => { };
11+
private Action<ExecutionOptions> _configureExecution = _ => { };
1012
private readonly TestServer _server;
1113

1214
public GetTests()
@@ -55,6 +57,14 @@ private class Query2
5557
{
5658
public static string? Ext(IResolveFieldContext context)
5759
=> context.InputExtensions.TryGetValue("test", out var value) ? value?.ToString() : null;
60+
61+
public static string? CustomError() => throw new CustomError();
62+
}
63+
64+
public class CustomError : ValidationError, IHasPreferredStatusCode
65+
{
66+
public CustomError() : base("Custom error") { }
67+
public HttpStatusCode PreferredStatusCode => HttpStatusCode.NotAcceptable;
5868
}
5969

6070
public void Dispose() => _server.Dispose();
@@ -250,6 +260,67 @@ public async Task Disabled()
250260
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
251261
}
252262

263+
[Theory]
264+
[InlineData(true)]
265+
[InlineData(false)]
266+
public async Task PreferredStatusCode_ExecutionErrors(bool badRequest)
267+
{
268+
_options2.ValidationErrorsReturnBadRequest = badRequest;
269+
var client = _server.CreateClient();
270+
using var response = await client.GetAsync("/graphql2?query={customError}");
271+
await response.ShouldBeAsync(
272+
HttpStatusCode.OK,
273+
"""{"errors":[{"message":"Custom error","locations":[{"line":1,"column":2}],"path":["customError"],"extensions":{"code":"CUSTOM","codes":["CUSTOM"]}}],"data":{"customError":null}}""");
274+
}
275+
276+
[Theory]
277+
[InlineData(true)]
278+
[InlineData(false)]
279+
public async Task PreferredStatusCode_ValidationErrors(bool badRequest)
280+
{
281+
_options2.ValidationErrorsReturnBadRequest = badRequest;
282+
var mockRule = new Mock<IValidationRule>(MockBehavior.Loose);
283+
mockRule.Setup(x => x.GetPreNodeVisitorAsync(It.IsAny<ValidationContext>())).Returns<ValidationContext>(context =>
284+
{
285+
context.ReportError(new CustomError());
286+
context.ReportError(new CustomError());
287+
return default;
288+
});
289+
_configureExecution = o =>
290+
{
291+
o.ValidationRules = (o.ValidationRules ?? DocumentValidator.CoreRules).Append(mockRule.Object);
292+
};
293+
var client = _server.CreateClient();
294+
using var response = await client.GetAsync("/graphql2?query={__typename}");
295+
await response.ShouldBeAsync(
296+
badRequest ? HttpStatusCode.NotAcceptable : HttpStatusCode.OK,
297+
"""{"errors":[{"message":"Custom error","extensions":{"code":"CUSTOM","codes":["CUSTOM"]}},{"message":"Custom error","extensions":{"code":"CUSTOM","codes":["CUSTOM"]}}]}""");
298+
}
299+
300+
[Theory]
301+
[InlineData(true)]
302+
[InlineData(false)]
303+
public async Task PreferredStatusCode_MixedValidationErrors(bool badRequest)
304+
{
305+
_options2.ValidationErrorsReturnBadRequest = badRequest;
306+
var mockRule = new Mock<IValidationRule>(MockBehavior.Loose);
307+
mockRule.Setup(x => x.GetPreNodeVisitorAsync(It.IsAny<ValidationContext>())).Returns<ValidationContext>(context =>
308+
{
309+
context.ReportError(new CustomError());
310+
context.ReportError(new ValidationError("test"));
311+
return default;
312+
});
313+
_configureExecution = o =>
314+
{
315+
o.ValidationRules = (o.ValidationRules ?? DocumentValidator.CoreRules).Append(mockRule.Object);
316+
};
317+
var client = _server.CreateClient();
318+
using var response = await client.GetAsync("/graphql2?query={__typename}");
319+
await response.ShouldBeAsync(
320+
badRequest ? HttpStatusCode.BadRequest : HttpStatusCode.OK,
321+
"""{"errors":[{"message":"Custom error","extensions":{"code":"CUSTOM","codes":["CUSTOM"]}},{"message":"test","extensions":{"code":"VALIDATION_ERROR","codes":["VALIDATION_ERROR"]}}]}""");
322+
}
323+
253324
[Theory]
254325
[InlineData(false)]
255326
[InlineData(true)]

0 commit comments

Comments
 (0)