Skip to content

Commit

Permalink
Automatic Persisted Queries support (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 authored May 6, 2022
1 parent 52ddbb9 commit 0f46b69
Show file tree
Hide file tree
Showing 10 changed files with 87 additions and 41 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<ImplicitUsings>true</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<GraphQLVersion>5.1.1</GraphQLVersion>
<GraphQLVersion>5.2.0</GraphQLVersion>
<NoWarn>$(NoWarn);IDE0057</NoWarn>
</PropertyGroup>

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ endpoint; the WebSocket handler options are configured globally via `AddWebSocke

| Property | Description | Default value |
|------------------------------------|-----------------|---------------|
| `AllowEmptyQuery` | If set, allows requests with no 'query' to be executed; useful when supporting Automatic Persisted Queries. | False |
| `AuthorizationRequired` | Requires `HttpContext.User` to represent an authenticated user. | False |
| `AuthorizedPolicy` | If set, requires `HttpContext.User` to pass authorization of the specified policy. | |
| `AuthorizedRoles` | If set, requires `HttpContext.User` to be a member of any one of a list of roles. | |
Expand Down Expand Up @@ -524,7 +525,6 @@ A list of methods are as follows:
| `HandleDeserializationErrorAsync` | Writes a '400 JSON body text could not be parsed.' message to the output. |
| `HandleInvalidContentTypeErrorAsync` | Writes a '415 Invalid Content-Type header: non-supported type.' message to the output. |
| `HandleInvalidHttpMethodErrorAsync` | Indicates that an unsupported HTTP method was requested. Executes the next delegate in the chain by default. |
| `HandleNoQueryErrorAsync` | Writes a '400 GraphQL query is missing.' message to the output. |
| `HandleWebSocketSubProtocolNotSupportedAsync` | Writes a '400 Invalid WebSocket sub-protocol.' message to the output. |

#### WebSocket handler classes
Expand Down
6 changes: 6 additions & 0 deletions migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ connections; supports and returns media type of `application/graphql+json` by de

Support for ASP.NET Core 2.1 added, tested with .NET Core 2.1 and .NET Framework 4.8.

Removed `HandleNoQueryErrorAsync` method; validation for this scenario already
exists within `ExecuteRequestAsync`.

Added `AllowEmptyQuery` option to allow for Automatic Persisted Queries if configured
through a custom `IDocumentExecuter`.

## 2.1.0

Authentication validation rule and support
Expand Down
42 changes: 16 additions & 26 deletions src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private static IEnumerable<IWebSocketHandler<TSchema>> CreateWebSocketHandlers(
}

/// <inheritdoc/>
protected override async Task<ExecutionResult> ExecuteScopedRequestAsync(HttpContext context, GraphQLRequest request, IDictionary<string, object?> userContext)
protected override async Task<ExecutionResult> ExecuteScopedRequestAsync(HttpContext context, GraphQLRequest? request, IDictionary<string, object?> userContext)
{
using var scope = _serviceScopeFactory.CreateScope();
try {
Expand All @@ -91,21 +91,22 @@ protected override async Task<ExecutionResult> ExecuteScopedRequestAsync(HttpCon
}

/// <inheritdoc/>
protected override async Task<ExecutionResult> ExecuteRequestAsync(HttpContext context, GraphQLRequest request, IServiceProvider serviceProvider, IDictionary<string, object?> userContext)
protected override async Task<ExecutionResult> ExecuteRequestAsync(HttpContext context, GraphQLRequest? request, IServiceProvider serviceProvider, IDictionary<string, object?> userContext)
{
if (request?.Query == null) {
if (!Options.AllowEmptyQuery && string.IsNullOrEmpty(request?.Query)) {
return new ExecutionResult {
Errors = new ExecutionErrors {
new QueryMissingError()
}
};
}

var opts = new ExecutionOptions {
Query = request.Query,
Variables = request.Variables,
Extensions = request.Extensions,
Query = request?.Query,
Variables = request?.Variables,
Extensions = request?.Extensions,
CancellationToken = context.RequestAborted,
OperationName = request.OperationName,
OperationName = request?.OperationName,
RequestServices = serviceProvider,
UserContext = userContext,
};
Expand Down Expand Up @@ -199,7 +200,7 @@ public virtual async Task InvokeAsync(HttpContext context)

// Parse POST body
GraphQLRequest? bodyGQLRequest = null;
IList<GraphQLRequest>? bodyGQLBatchRequest = null;
IList<GraphQLRequest?>? bodyGQLBatchRequest = null;
if (isPost) {
if (!MediaTypeHeaderValue.TryParse(httpRequest.ContentType, out var mediaTypeHeader)) {
await HandleContentTypeCouldNotBeParsedErrorAsync(context, _next);
Expand All @@ -209,7 +210,7 @@ public virtual async Task InvokeAsync(HttpContext context)
switch (mediaTypeHeader.MediaType?.ToLowerInvariant()) {
case MEDIATYPE_GRAPHQLJSON:
case MEDIATYPE_JSON:
IList<GraphQLRequest>? deserializationResult;
IList<GraphQLRequest?>? deserializationResult;
try {
#if NET5_0_OR_GREATER
if (!TryGetEncoding(mediaTypeHeader.CharSet, out var sourceEncoding)) {
Expand All @@ -219,12 +220,12 @@ public virtual async Task InvokeAsync(HttpContext context)
// Wrap content stream into a transcoding stream that buffers the data transcoded from the sourceEncoding to utf-8.
if (sourceEncoding != null && sourceEncoding != System.Text.Encoding.UTF8) {
using var tempStream = System.Text.Encoding.CreateTranscodingStream(httpRequest.Body, innerStreamEncoding: sourceEncoding, outerStreamEncoding: System.Text.Encoding.UTF8, leaveOpen: true);
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest>>(tempStream, context.RequestAborted);
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest?>>(tempStream, context.RequestAborted);
} else {
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest>>(httpRequest.Body, context.RequestAborted);
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest?>>(httpRequest.Body, context.RequestAborted);
}
#else
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest>>(httpRequest.Body, context.RequestAborted);
deserializationResult = await _serializer.ReadAsync<IList<GraphQLRequest?>>(httpRequest.Body, context.RequestAborted);
#endif
} catch (Exception ex) {
if (!await HandleDeserializationErrorAsync(context, _next, ex))
Expand Down Expand Up @@ -281,11 +282,6 @@ public virtual async Task InvokeAsync(HttpContext context)
OperationName = urlGQLRequest?.OperationName ?? bodyGQLRequest?.OperationName
};

if (string.IsNullOrWhiteSpace(gqlRequest.Query)) {
await HandleNoQueryErrorAsync(context, _next);
return;
}

// Prepare context and execute
await HandleRequestAsync(context, _next, gqlRequest);
} else if (Options.EnableBatchedRequests) {
Expand Down Expand Up @@ -349,7 +345,7 @@ protected virtual async Task HandleRequestAsync(
protected virtual async Task HandleBatchRequestAsync(
HttpContext context,
RequestDelegate next,
IList<GraphQLRequest> gqlRequests)
IList<GraphQLRequest?> gqlRequests)
{
var userContext = await BuildUserContextAsync(context, null);
var results = new ExecutionResult[gqlRequests.Count];
Expand Down Expand Up @@ -382,7 +378,7 @@ protected virtual async Task HandleBatchRequestAsync(
/// <see cref="ExecuteRequestAsync(HttpContext, GraphQLRequest, IServiceProvider, IDictionary{string, object?})">ExecuteRequestAsync</see>,
/// disposing of the scope when the asynchronous operation completes.
/// </summary>
protected abstract Task<ExecutionResult> ExecuteScopedRequestAsync(HttpContext context, GraphQLRequest request, IDictionary<string, object?> userContext);
protected abstract Task<ExecutionResult> ExecuteScopedRequestAsync(HttpContext context, GraphQLRequest? request, IDictionary<string, object?> userContext);

/// <summary>
/// Executes a GraphQL request.
Expand All @@ -399,7 +395,7 @@ protected virtual async Task HandleBatchRequestAsync(
/// options.CachedDocumentValidationRules = new[] { rule };
/// </code>
/// </summary>
protected abstract Task<ExecutionResult> ExecuteRequestAsync(HttpContext context, GraphQLRequest request, IServiceProvider serviceProvider, IDictionary<string, object?> userContext);
protected abstract Task<ExecutionResult> ExecuteRequestAsync(HttpContext context, GraphQLRequest? request, IServiceProvider serviceProvider, IDictionary<string, object?> userContext);

/// <summary>
/// Builds the user context based on a <see cref="HttpContext"/>.
Expand Down Expand Up @@ -533,12 +529,6 @@ protected virtual Task HandleBatchedRequestsNotSupportedAsync(HttpContext contex
protected virtual Task HandleWebSocketSubProtocolNotSupportedAsync(HttpContext context, RequestDelegate next)
=> WriteErrorResponseAsync(context, HttpStatusCode.BadRequest, new WebSocketSubProtocolNotSupportedError(context.WebSockets.WebSocketRequestedProtocols));

/// <summary>
/// Writes a '400 GraphQL query is missing.' message to the output.
/// </summary>
protected virtual Task HandleNoQueryErrorAsync(HttpContext context, RequestDelegate next)
=> WriteErrorResponseAsync(context, Options.ValidationErrorsReturnBadRequest ? HttpStatusCode.BadRequest : HttpStatusCode.OK, new QueryMissingError());

/// <summary>
/// Writes a '415 Invalid Content-Type header: could not be parsed.' message to the output.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,11 @@ public class GraphQLHttpMiddlewareOptions
/// Returns an options class for WebSocket connections.
/// </summary>
public GraphQLWebSocketOptions WebSockets { get; set; } = new();

/// <summary>
/// If set, allows requests with no 'query' to execute. This is useful when
/// supporting Automatic Persisted Queries (either by a custom <see cref="IDocumentExecuter"/>,
/// or by overriding <see cref="GraphQLHttpMiddleware.ExecuteRequestAsync(HttpContext, GraphQLRequest, IServiceProvider, IDictionary{string, object?})">ExecuteRequestAsync</see>).
/// </summary>
public bool AllowEmptyQuery { get; set; }
}
12 changes: 6 additions & 6 deletions src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,16 @@ namespace GraphQL.AspNetCore3
public GraphQLHttpMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, GraphQL.IGraphQLTextSerializer serializer, GraphQL.AspNetCore3.GraphQLHttpMiddlewareOptions options, System.Collections.Generic.IEnumerable<GraphQL.AspNetCore3.IWebSocketHandler>? webSocketHandlers = null) { }
protected GraphQL.AspNetCore3.GraphQLHttpMiddlewareOptions Options { get; }
protected virtual System.Threading.Tasks.ValueTask<System.Collections.Generic.IDictionary<string, object?>> BuildUserContextAsync(Microsoft.AspNetCore.Http.HttpContext context, object? payload) { }
protected abstract System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest request, System.IServiceProvider serviceProvider, System.Collections.Generic.IDictionary<string, object?> userContext);
protected abstract System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteScopedRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest request, System.Collections.Generic.IDictionary<string, object?> userContext);
protected abstract System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest? request, System.IServiceProvider serviceProvider, System.Collections.Generic.IDictionary<string, object?> userContext);
protected abstract System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteScopedRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest? request, System.Collections.Generic.IDictionary<string, object?> userContext);
protected virtual System.Threading.Tasks.ValueTask<bool> HandleAuthorizeAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.ValueTask<bool> HandleAuthorizeWebSocketConnectionAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleBatchRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest> gqlRequests) { }
protected virtual System.Threading.Tasks.Task HandleBatchRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Collections.Generic.IList<GraphQL.Transport.GraphQLRequest?> gqlRequests) { }
protected virtual System.Threading.Tasks.Task HandleBatchedRequestsNotSupportedAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleContentTypeCouldNotBeParsedErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.ValueTask<bool> HandleDeserializationErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, System.Exception exception) { }
protected virtual System.Threading.Tasks.Task HandleInvalidContentTypeErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleInvalidHttpMethodErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleNoQueryErrorAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleNotAuthenticatedAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
protected virtual System.Threading.Tasks.Task HandleNotAuthorizedPolicyAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next, Microsoft.AspNetCore.Authorization.AuthorizationResult authorizationResult) { }
protected virtual System.Threading.Tasks.Task HandleNotAuthorizedRoleAsync(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Http.RequestDelegate next) { }
Expand All @@ -134,6 +133,7 @@ namespace GraphQL.AspNetCore3
public class GraphQLHttpMiddlewareOptions
{
public GraphQLHttpMiddlewareOptions() { }
public bool AllowEmptyQuery { get; set; }
public bool AuthorizationRequired { get; set; }
public string? AuthorizedPolicy { get; set; }
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }
Expand All @@ -153,8 +153,8 @@ namespace GraphQL.AspNetCore3
{
protected GraphQLHttpMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, GraphQL.IGraphQLTextSerializer serializer, GraphQL.IDocumentExecuter<TSchema> documentExecuter, Microsoft.Extensions.DependencyInjection.IServiceScopeFactory serviceScopeFactory, GraphQL.AspNetCore3.GraphQLHttpMiddlewareOptions options, System.Collections.Generic.IEnumerable<GraphQL.AspNetCore3.IWebSocketHandler<TSchema>>? webSocketHandlers = null) { }
public GraphQLHttpMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, GraphQL.IGraphQLTextSerializer serializer, GraphQL.IDocumentExecuter<TSchema> documentExecuter, Microsoft.Extensions.DependencyInjection.IServiceScopeFactory serviceScopeFactory, GraphQL.AspNetCore3.GraphQLHttpMiddlewareOptions options, System.IServiceProvider provider, Microsoft.Extensions.Hosting.IHostApplicationLifetime hostApplicationLifetime) { }
protected override System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest request, System.IServiceProvider serviceProvider, System.Collections.Generic.IDictionary<string, object?> userContext) { }
protected override System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteScopedRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest request, System.Collections.Generic.IDictionary<string, object?> userContext) { }
protected override System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest? request, System.IServiceProvider serviceProvider, System.Collections.Generic.IDictionary<string, object?> userContext) { }
protected override System.Threading.Tasks.Task<GraphQL.ExecutionResult> ExecuteScopedRequestAsync(Microsoft.AspNetCore.Http.HttpContext context, GraphQL.Transport.GraphQLRequest? request, System.Collections.Generic.IDictionary<string, object?> userContext) { }
}
public sealed class HttpGetValidationRule : GraphQL.Validation.IValidationRule
{
Expand Down
16 changes: 15 additions & 1 deletion src/Tests/Middleware/BatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public class BatchTests : IDisposable
{
private GraphQLHttpMiddlewareOptions _options = null!;
private GraphQLHttpMiddlewareOptions _options2 = null!;
private Action<ExecutionOptions> _configureExecution = _ => { };
private readonly TestServer _server;

public BatchTests()
Expand All @@ -16,7 +17,8 @@ public BatchTests()
.WithMutation<Chat.Schema.Mutation>()
.WithSubscription<Chat.Schema.Subscription>())
.AddSchema<Schema2>()
.AddSystemTextJson());
.AddSystemTextJson()
.ConfigureExecutionOptions(o => _configureExecution(o)));
#if NETCOREAPP2_1 || NET48
services.AddHostApplicationLifetime();
#endif
Expand Down Expand Up @@ -117,6 +119,18 @@ public async Task QueryParseError(bool badRequest)
await response.ShouldBeAsync(false, @"[{""errors"":[{""message"":""Error parsing query: Expected Name, found EOF; for more information see http://spec.graphql.org/October2021/#Field"",""locations"":[{""line"":1,""column"":2}],""extensions"":{""code"":""SYNTAX_ERROR"",""codes"":[""SYNTAX_ERROR""]}}]}]");
}

[Fact]
public async Task NoQuery_Allowed()
{
_configureExecution = o => {
if (string.IsNullOrEmpty(o.Query))
o.Query = "{count}";
};
_options.AllowEmptyQuery = true;
using var response = await PostJsonAsync(@"[{}]");
await response.ShouldBeAsync(false, @"[{""data"":{""count"":0}}]");
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand Down
Loading

0 comments on commit 0f46b69

Please sign in to comment.