Skip to content

Commit

Permalink
Experimental @OneOf directive support for input types (#1723)
Browse files Browse the repository at this point in the history
pekkah authored Jan 11, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 18f7269 commit 69eb84b
Showing 25 changed files with 551 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\GraphQL.Language\GraphQL.Language.csproj" />
<ProjectReference Include="..\GraphQL\GraphQL.csproj" />
</ItemGroup>

</Project>
66 changes: 66 additions & 0 deletions src/GraphQL.Extensions.Experimental/OneOf/OneOfDirective.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using Tanka.GraphQL.Language;
using Tanka.GraphQL.Language.Nodes;
using Tanka.GraphQL.Language.Nodes.TypeSystem;
using Tanka.GraphQL.TypeSystem;
using Tanka.GraphQL.Validation;

namespace Tanka.GraphQL.Extensions.Experimental.OneOf;

public class OneOfDirective
{
private static readonly List<NodeKind> AllowedKinds = [NodeKind.ObjectValue, NodeKind.Variable];

public static DirectiveDefinition Directive =>
$"directive @oneOf on {TypeSystemDirectiveLocations.INPUT_OBJECT}";

public static CombineRule OneOfValidationRule()
{
return (context, rule) =>
{
rule.EnterArgument += argument =>
{
InputValueDefinition? argumentDefinition = context.Tracker.ArgumentDefinition;

if (argumentDefinition is null)
return;

if (!AllowedKinds.Contains(argument.Value.Kind))
return;

if (context.Schema.GetNamedType(argumentDefinition.Type.Unwrap().Name) is not InputObjectDefinition
inputObject)
return;

if (!inputObject.HasDirective(Directive.Name))
return;

if (argument.Value.Kind == NodeKind.Variable)
{
var variable = (Variable)argument.Value;

if (context.VariableValues?.TryGetValue(variable.Name, out object? variableValue) != true) return;

if (variableValue is not null)
{
var coercedValue = Values.CoerceValue(
context.Schema,
variableValue,
argumentDefinition.Type) as IReadOnlyDictionary<string, object?>;

if (coercedValue?.Count(kv => kv.Value is not null) != 1)
context.Error("ONEOF001",
$"Invalid value for '@oneOf' input '{inputObject.Name}'. @oneOf input objects can only have one field value set.");
}
}
else
{
var objectValue = (ObjectValue)argument.Value;

if (objectValue.Count != 1)
context.Error("ONEOF001",
$"Invalid value for '@oneOf' input '{inputObject.Name}'. @oneOf input objects can only have one field value set.");
}
};
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Tanka.GraphQL.TypeSystem;

namespace Tanka.GraphQL.Extensions.Experimental.OneOf;

public static class SchemaBuilderExtensions
{
/// <summary>
/// Add support for the @oneOf directive.
/// </summary>
/// <param name="builder"></param>
/// <returns></returns>
public static SchemaBuilder AddOneOf(this SchemaBuilder builder)
{
builder.Add(OneOfDirective.Directive);
return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;

namespace Tanka.GraphQL.Extensions.Experimental.OneOf;

public static class ServiceCollectionExtensions
{
/// <summary>
/// Add @oneOf directive validation rule to default validator rules
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
public static IServiceCollection AddOneOfValidationRule(this IServiceCollection services)
{
services.AddDefaultValidatorRule(OneOfDirective.OneOfValidationRule());
return services;
}
}
27 changes: 10 additions & 17 deletions src/GraphQL.Language/Nodes/ObjectValue.cs
Original file line number Diff line number Diff line change
@@ -3,33 +3,26 @@

namespace Tanka.GraphQL.Language.Nodes;

public sealed class ObjectValue : ValueBase, ICollectionNode<ObjectField>
public sealed class ObjectValue(
IReadOnlyList<ObjectField> fields,
in Location? location = default)
: ValueBase, ICollectionNode<ObjectField>
{
//todo: remove?
public readonly IReadOnlyList<ObjectField> Fields;

public ObjectValue(
IReadOnlyList<ObjectField> fields,
in Location? location = default)
{
Fields = fields;
Location = location;
}

public override NodeKind Kind => NodeKind.ObjectValue;

public override Location? Location { get; }
public override Location? Location { get; } = location;

public IEnumerator<ObjectField> GetEnumerator()
{
return Fields.GetEnumerator();
return fields.GetEnumerator();
}

IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)Fields).GetEnumerator();
return ((IEnumerable)fields).GetEnumerator();
}

public int Count => Fields.Count;
public ObjectField this[int index] => Fields[index];
public int Count => fields.Count;

public ObjectField this[int index] => fields[index];
}
5 changes: 3 additions & 2 deletions src/GraphQL.Server/GraphQLApplicationBuilder.cs
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ public GraphQLApplicationBuilder(IServiceCollection applicationServices)
ApplicationOptionsBuilder = ApplicationServices
.AddOptions<GraphQLApplicationOptions>();

AddCore();
AddDefaultTankaGraphQLServerServices();
}

public IServiceCollection ApplicationServices { get; }
@@ -55,10 +55,11 @@ public GraphQLApplicationBuilder AddWebSockets()
return this;
}

private void AddCore()
private void AddDefaultTankaGraphQLServerServices()
{
ApplicationServices.TryAddSingleton<IHostedService, SchemaInitializer>();
ApplicationServices.TryAddSingleton<SchemaCollection>();
ApplicationServices.TryAddSingleton<GraphQLApplication>();
ApplicationServices.AddDefaultTankaGraphQLServices();
}
}
13 changes: 3 additions & 10 deletions src/GraphQL.Server/WebSockets/ClientMethods.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Threading.Channels;

namespace Tanka.GraphQL.Server.WebSockets;

public class ClientMethods
public class ClientMethods(ChannelWriter<MessageBase> writer)
{
protected ChannelWriter<MessageBase> Writer { get; }

public ClientMethods(ChannelWriter<MessageBase> writer)
{
Writer = writer;
}
protected ChannelWriter<MessageBase> Writer { get; } = writer;

public async Task ConnectionAck(ConnectionAck connectionAck, CancellationToken cancellationToken)
{
32 changes: 30 additions & 2 deletions src/GraphQL.Server/WebSockets/ServerMethods.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
using System.Collections.Concurrent;
using System.Diagnostics;

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using Tanka.GraphQL.Fields;
using Tanka.GraphQL.Request;
using Tanka.GraphQL.Server.WebSockets.WebSocketPipe;
using Tanka.GraphQL.Validation;

namespace Tanka.GraphQL.Server.WebSockets;

public class ServerMethods
public partial class ServerMethods
{
private readonly GraphQLRequestDelegate _requestDelegate;
private readonly HttpContext _httpContext;
@@ -20,11 +23,14 @@ public ServerMethods(WebSocketMessageChannel<MessageBase> channel, GraphQLReques
_httpContext = httpContext;
Channel = channel;
Client = new ClientMethods(Channel.Writer);
_logger = httpContext.RequestServices.GetRequiredService<ILogger<ServerMethods>>();
}

public ClientMethods Client { get; set; }

public ConcurrentDictionary<string, (CancellationTokenSource Unsubscribe, Task Worker)> Subscriptions = new();

private readonly ILogger<ServerMethods> _logger;

public async Task ConnectionInit(ConnectionInit connectionInit, CancellationToken cancellationToken)
{
@@ -57,6 +63,7 @@ await Channel.Complete(

private async Task Execute(Subscribe subscribe, CancellationTokenSource unsubscribeOrAborted)
{
_ = _logger.BeginScope(subscribe.Id);
var cancellationToken = unsubscribeOrAborted.Token;
var context = new GraphQLRequestContext
{
@@ -73,18 +80,27 @@ private async Task Execute(Subscribe subscribe, CancellationTokenSource unsubscr

try
{
ulong count = 0;
Log.Request(_logger, subscribe.Id, context.Request);
await _requestDelegate(context);
await using var enumerator = context.Response.GetAsyncEnumerator(cancellationToken);

long started = Stopwatch.GetTimestamp();
while (await enumerator.MoveNextAsync())
{
count++;
string elapsed = $"{Stopwatch.GetElapsedTime(started).TotalMilliseconds}ms";
Log.ExecutionResult(_logger, subscribe.Id, enumerator.Current, elapsed);
await Client.Next(new Next() { Id = subscribe.Id, Payload = enumerator.Current }, cancellationToken);
started = Stopwatch.GetTimestamp();
}

if (!cancellationToken.IsCancellationRequested)
{
await Client.Complete(new Complete() { Id = subscribe.Id }, cancellationToken);
}

Log.Completed(_logger, subscribe.Id, count);
}
catch (OperationCanceledException)
{
@@ -141,4 +157,16 @@ public async Task Complete(Complete complete, CancellationToken cancellationToke
await worker;
}
}

private static partial class Log
{
[LoggerMessage(5, LogLevel.Debug, "Subscription({Id}) - Result({elapsed}): {result}")]
public static partial void ExecutionResult(ILogger logger, string id, ExecutionResult? result, string elapsed);

[LoggerMessage(3, LogLevel.Debug, "Subscription({Id}) - Request: {request}")]
public static partial void Request(ILogger logger, string id, GraphQLRequest request);

[LoggerMessage(10, LogLevel.Information, "Subscription({Id}) - Server stream completed. {count} messages sent.")]
public static partial void Completed(ILogger logger, string id, ulong count);
}
}
2 changes: 1 addition & 1 deletion src/GraphQL/DefaultOperationPipelineBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -94,7 +94,7 @@ public static OperationDelegateBuilder UseDefaults(this OperationDelegateBuilder

public static OperationDelegateBuilder UseDefaultValidator(this OperationDelegateBuilder builder)
{
var validator = new Validator3(ExecutionRules.All);
var validator = builder.ApplicationServices.GetRequiredService<IAsyncValidator>();

builder.Use(next => async context =>
{
6 changes: 3 additions & 3 deletions src/GraphQL/Executor.cs
Original file line number Diff line number Diff line change
@@ -11,8 +11,6 @@ namespace Tanka.GraphQL;
/// </summary>
public partial class Executor
{
private static readonly IServiceProvider EmptyProvider = new ServiceCollection().BuildServiceProvider();

private readonly OperationDelegate _operationDelegate;

/// <summary>
@@ -29,7 +27,9 @@ public Executor(ISchema schema) : this(new ExecutorOptions { Schema = schema })
/// <param name="options"></param>
public Executor(ExecutorOptions options)
{
OperationDelegateBuilder builder = new(options.ServiceProvider ?? EmptyProvider);
OperationDelegateBuilder builder = new(options.ServiceProvider ?? new ServiceCollection()
.AddDefaultTankaGraphQLServices()
.BuildServiceProvider());

if (options.TraceEnabled)
builder.UseTrace();
31 changes: 31 additions & 0 deletions src/GraphQL/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;

using Tanka.GraphQL.Validation;

namespace Tanka.GraphQL;

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDefaultTankaGraphQLServices(this IServiceCollection services)
{
return services.AddDefaultValidator();
}

public static IServiceCollection AddDefaultValidator(this IServiceCollection services)
{
services.AddOptions();
services.AddOptions<AsyncValidatorOptions>();
services.TryAddSingleton<IAsyncValidator>(p =>
new AsyncValidator(p.GetRequiredService<IOptions<AsyncValidatorOptions>>()));

return services;
}

public static IServiceCollection AddDefaultValidatorRule(this IServiceCollection services, CombineRule rule)
{
services.PostConfigure<AsyncValidatorOptions>(options => options.Rules.Add(rule));
return services;
}
}
Loading

0 comments on commit 69eb84b

Please sign in to comment.