From 7f0bf585e26f1cc1bc3efe040fe8c73f7f1c8953 Mon Sep 17 00:00:00 2001 From: Glen Date: Mon, 22 Jun 2026 14:56:08 +0200 Subject: [PATCH] Add directive-argument opt-in and code-first directive APIs --- .../Validation/RequiresOptInValidationRule.cs | 15 ++ ...aRequestExecutorBuilderExtensions.Types.cs | 25 +++ .../SchemaBuilderExtensions.Types.cs | 10 ++ .../Contracts/IDirectiveArgumentDescriptor.cs | 51 ++++++ .../Contracts/IDirectiveTypeDescriptor.cs | 14 ++ .../DirectiveArgumentDescriptor.cs | 25 +++ .../Descriptors/DirectiveTypeDescriptor.cs | 11 ++ .../RequiresOptInDirectiveExtensions.cs | 28 +++ .../OptInFeaturesTypeInterceptor.cs | 27 ++- .../Types/Types/Introspection/__Directive.cs | 45 ++++- .../Core/src/Types/Utilities/ErrorHelper.cs | 9 + .../Execution.Tests/IntrospectionTests.cs | 8 +- .../OptInFeaturesIntrospectionTests.cs | 166 ++++++++++++++++++ .../AnnotationBasedMutations.cs | 23 ++- .../Configuration/TypeTrimmerTests.cs | 20 +-- .../Validation/RequiresOptInValidation.cs | 38 ++++ ...Appear_On_Required_Directive_Argument.snap | 7 + .../SchemaBuilderExtensions.Types.Tests.cs | 24 ++- .../Types.Tests/Types/DirectiveTypeTests.cs | 84 +++++---- .../Directives/RequiresOptInDirectiveTests.cs | 7 + ...emaAsync_CodeFirst_MatchesSnapshot.graphql | 4 + .../test/Types.Tests/Types/EnumTypeTests.cs | 16 +- .../Interceptors/FlagEnumInterceptorTests.cs | 11 +- .../test/Validation.Tests/ValidationUtils.cs | 4 +- 24 files changed, 589 insertions(+), 83 deletions(-) create mode 100644 src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/__snapshots__/RequiresOptInValidation.Must_Not_Appear_On_Required_Directive_Argument.snap diff --git a/src/HotChocolate/Core/src/Types/Configuration/Validation/RequiresOptInValidationRule.cs b/src/HotChocolate/Core/src/Types/Configuration/Validation/RequiresOptInValidationRule.cs index 26a470652fd..701721d1803 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/Validation/RequiresOptInValidationRule.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/Validation/RequiresOptInValidationRule.cs @@ -55,5 +55,20 @@ public void Validate( break; } } + + foreach (var directive in schema.DirectiveDefinitions) + { + foreach (var argument in directive.Arguments) + { + if (argument.Type.IsNonNullType() + && argument.DefaultValue is null + && argument.Directives.Any(d => d.Definition is RequiresOptInDirectiveType)) + { + errors.Add(RequiresOptInOnRequiredDirectiveArgument( + directive, + argument)); + } + } + } } } diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.Types.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.Types.cs index bd793e5b04f..a9abf343465 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.Types.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/SchemaRequestExecutorBuilderExtensions.Types.cs @@ -1159,6 +1159,31 @@ public static IRequestExecutorBuilder AddDirectiveType( return builder.ConfigureSchema(b => b.AddDirectiveType(directiveType)); } + /// + /// This helper adds a new GraphQL directive type and applies the + /// delegate. + /// + /// + /// The GraphQL configuration builder. + /// + /// + /// A delegate to configure the type. + /// + /// + /// Returns the GraphQL configuration builder. + /// + /// + /// or is null + /// + public static IRequestExecutorBuilder AddDirectiveType( + this IRequestExecutorBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + return builder.ConfigureSchema(b => b.AddDirectiveType(configure)); + } + public static IRequestExecutorBuilder SetSchema( this IRequestExecutorBuilder builder) where TSchema : Schema diff --git a/src/HotChocolate/Core/src/Types/Extensions/SchemaBuilderExtensions.Types.cs b/src/HotChocolate/Core/src/Types/Extensions/SchemaBuilderExtensions.Types.cs index 48df2a4b878..dd92a921e08 100644 --- a/src/HotChocolate/Core/src/Types/Extensions/SchemaBuilderExtensions.Types.cs +++ b/src/HotChocolate/Core/src/Types/Extensions/SchemaBuilderExtensions.Types.cs @@ -468,6 +468,16 @@ public static ISchemaBuilder AddDirectiveType( return AddDirectiveType(builder, typeof(TDirective)); } + public static ISchemaBuilder AddDirectiveType( + this ISchemaBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + return builder.AddDirectiveType(new DirectiveType(configure)); + } + public static ISchemaBuilder SetSchema( this ISchemaBuilder builder) where TSchema : Schema diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveArgumentDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveArgumentDescriptor.cs index c3d67de3824..d137c82ece7 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveArgumentDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveArgumentDescriptor.cs @@ -15,6 +15,57 @@ public interface IDirectiveArgumentDescriptor /// IDirectiveArgumentDescriptor Deprecated(); + /// + /// Sets a directive on the argument + /// + /// + /// descriptor.Directive(new MyDirective()); + /// + /// Results in the following schema + /// + /// directive @example(arg: Int @myDirective) on FIELD + /// + /// + /// + /// + /// The instance of the directive + /// + /// The type of the directive + IDirectiveArgumentDescriptor Directive(T directiveInstance) + where T : class; + + /// + /// Sets a directive on the argument + /// + /// + /// descriptor.Directive<MyDirective>(); + /// + /// Results in the following schema + /// + /// directive @example(arg: Int @myDirective) on FIELD + /// + /// + /// + /// The type of the directive + IDirectiveArgumentDescriptor Directive() + where T : class, new(); + + /// + /// Sets a directive on the argument + /// + /// + /// descriptor.Directive("myDirective"); + /// + /// Results in the following schema + /// + /// directive @example(arg: Int @myDirective) on FIELD + /// + /// + /// + /// The name of the directive + /// The arguments of the directive + IDirectiveArgumentDescriptor Directive(string name, params ArgumentNode[] arguments); + /// /// Sets the name of the argument /// diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveTypeDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveTypeDescriptor.cs index f1eb28ffeca..ae2b16a1908 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveTypeDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Contracts/IDirectiveTypeDescriptor.cs @@ -43,6 +43,20 @@ public interface IDirectiveTypeDescriptor /// The name of the argument. IDirectiveArgumentDescriptor Argument(string name); + /// + /// Specifies a directive argument and applies the + /// delegate. + /// + /// + /// The name of the argument. + /// + /// + /// The argument descriptor to specify the argument configuration. + /// + IDirectiveTypeDescriptor Argument( + string name, + Action configure); + /// /// Specifies in which location the directive belongs in. /// diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveArgumentDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveArgumentDescriptor.cs index 23f33f0fd6e..e0dcbcde939 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveArgumentDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveArgumentDescriptor.cs @@ -100,6 +100,31 @@ public IDirectiveArgumentDescriptor Name(string value) return this; } + /// + public new IDirectiveArgumentDescriptor Directive(T directiveInstance) + where T : class + { + base.Directive(directiveInstance); + return this; + } + + /// + public new IDirectiveArgumentDescriptor Directive() + where T : class, new() + { + base.Directive(); + return this; + } + + /// + public new IDirectiveArgumentDescriptor Directive( + string name, + params ArgumentNode[] arguments) + { + base.Directive(name, arguments); + return this; + } + /// public new IDirectiveArgumentDescriptor Description(string value) { diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveTypeDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveTypeDescriptor.cs index cf885e74193..79d717dc331 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveTypeDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/DirectiveTypeDescriptor.cs @@ -135,6 +135,17 @@ public IDirectiveArgumentDescriptor Argument(string name) return descriptor; } + public IDirectiveTypeDescriptor Argument( + string name, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var descriptor = Argument(name); + configure(descriptor); + return this; + } + public IDirectiveTypeDescriptor Location(DirectiveLocation value) { Configuration.Locations |= value; diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/RequiresOptInDirectiveExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Directives/RequiresOptInDirectiveExtensions.cs index 92ca74e6b57..4acd648fa46 100644 --- a/src/HotChocolate/Core/src/Types/Types/Directives/RequiresOptInDirectiveExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Types/Directives/RequiresOptInDirectiveExtensions.cs @@ -86,6 +86,30 @@ public static IArgumentDescriptor RequiresOptIn( return descriptor; } + /// + /// Adds an @requiresOptIn directive to a . + /// + /// directive @example(arg: Int @requiresOptIn(feature: "your-feature")) on FIELD + /// + /// + /// + /// The on which this directive shall be annotated. + /// + /// + /// The name of the feature that requires opt in. + /// + /// + /// Returns the on which this directive + /// was applied for configuration chaining. + /// + public static IDirectiveArgumentDescriptor RequiresOptIn( + this IDirectiveArgumentDescriptor descriptor, + string feature) + { + ApplyRequiresOptIn(descriptor, feature); + return descriptor; + } + /// /// Adds an @requiresOptIn directive to an . /// @@ -132,6 +156,10 @@ private static void ApplyRequiresOptIn(this IDescriptor descriptor, string featu desc.Directive(new RequiresOptIn(feature)); break; + case IDirectiveArgumentDescriptor desc: + desc.Directive(new RequiresOptIn(feature)); + break; + case IEnumValueDescriptor desc: desc.Directive(new RequiresOptIn(feature)); break; diff --git a/src/HotChocolate/Core/src/Types/Types/Interceptors/OptInFeaturesTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Types/Interceptors/OptInFeaturesTypeInterceptor.cs index 89c2dc922f3..85d3bcc13e0 100644 --- a/src/HotChocolate/Core/src/Types/Types/Interceptors/OptInFeaturesTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Interceptors/OptInFeaturesTypeInterceptor.cs @@ -1,4 +1,5 @@ using HotChocolate.Configuration; +using HotChocolate.Language; using HotChocolate.Types.Descriptors; using HotChocolate.Types.Descriptors.Configurations; @@ -27,6 +28,12 @@ public override void OnBeforeCompleteType( { switch (configuration) { + case DirectiveTypeConfiguration directiveType: + _optInFeatures.UnionWith( + directiveType.Arguments.SelectMany(a => a.GetOptInFeatures())); + + break; + case EnumTypeConfiguration enumType: _optInFeatures.UnionWith(enumType.Values.SelectMany(v => v.GetOptInFeatures())); @@ -61,10 +68,22 @@ file static class Extensions public static IEnumerable GetOptInFeatures( this IDirectiveConfigurationProvider configuration) { - return configuration.Directives - .Select(d => d.Value) - .OfType() - .Select(r => r.Feature); + foreach (var directive in configuration.Directives) + { + switch (directive.Value) + { + case RequiresOptIn requiresOptIn: + yield return requiresOptIn.Feature; + break; + + case DirectiveNode { Name.Value: DirectiveNames.RequiresOptIn.Name } node + when node.Arguments.FirstOrDefault( + a => a.Name.Value == DirectiveNames.RequiresOptIn.Arguments.Feature) + is { Value: StringValueNode feature }: + yield return feature.Value; + break; + } + } } } diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__Directive.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__Directive.cs index 93f4f95b906..678710426a5 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/__Directive.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__Directive.cs @@ -16,11 +16,14 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon { var stringType = Create(ScalarNames.String); var nonNullStringType = Parse($"{ScalarNames.String}!"); + var nonNullStringListType = Parse($"[{ScalarNames.String}!]"); var nonNullBooleanType = Parse($"{ScalarNames.Boolean}!"); var argumentListType = Parse($"[{nameof(__InputValue)}!]!"); var locationListType = Parse($"[{nameof(__DirectiveLocation)}!]!"); - return new ObjectTypeConfiguration( + var optInFeaturesEnabled = context.DescriptorContext.Options.EnableOptInFeatures; + + var def = new ObjectTypeConfiguration( Names.__Directive, TypeResources.Directive_Description, typeof(DirectiveType)) @@ -30,7 +33,12 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon new(Names.Name, type: nonNullStringType, pureResolver: Resolvers.Name), new(Names.Description, type: stringType, pureResolver: Resolvers.Description), new(Names.Locations, type: locationListType, pureResolver: Resolvers.Locations), - new(Names.Args, type: argumentListType, pureResolver: Resolvers.Arguments) + new( + Names.Args, + type: argumentListType, + pureResolver: optInFeaturesEnabled + ? Resolvers.ArgumentsWithOptIn + : Resolvers.Arguments) { Arguments = { @@ -70,6 +78,15 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon } } }; + + if (optInFeaturesEnabled) + { + def.Fields.Single(f => f.Name == Names.Args) + .Arguments + .Add(new(Names.IncludeOptIn, type: nonNullStringListType)); + } + + return def; } private static class Resolvers @@ -95,7 +112,28 @@ public static object Locations(IResolverContext context) return DirectiveLocationUtils.AsEnumerable(locations); } - public static object Arguments(IResolverContext context) + public static object ArgumentsWithOptIn(IResolverContext context) + { + var includeOptIn = context.ArgumentValue(Names.IncludeOptIn) ?? []; + + // If an argument has no @requiresOptIn directives, it is always included. + // If an argument requires opting into features "f1" and "f2", then `includeOptIn` + // must list at least one of the features in order for the argument to be included. + return Arguments(context).Where( + a => + { + var requiredFeatures = a + .Directives + .Where(d => d.Definition is RequiresOptInDirectiveType) + .Select(d => d.ToValue().Feature) + .ToList(); + + return requiredFeatures.Count == 0 + || requiredFeatures.Any(feature => includeOptIn.Contains(feature)); + }); + } + + public static IEnumerable Arguments(IResolverContext context) { var directive = context.Parent(); return context.ArgumentValue(Names.IncludeDeprecated) @@ -132,6 +170,7 @@ public static class Names public const string IsDeprecated = "isDeprecated"; public const string DeprecationReason = "deprecationReason"; public const string IncludeDeprecated = "includeDeprecated"; + public const string IncludeOptIn = "includeOptIn"; public const string Locations = "locations"; public const string Args = "args"; public const string OnOperation = "onOperation"; diff --git a/src/HotChocolate/Core/src/Types/Utilities/ErrorHelper.cs b/src/HotChocolate/Core/src/Types/Utilities/ErrorHelper.cs index 7409741cf79..93c8de914e9 100644 --- a/src/HotChocolate/Core/src/Types/Utilities/ErrorHelper.cs +++ b/src/HotChocolate/Core/src/Types/Utilities/ErrorHelper.cs @@ -578,4 +578,13 @@ public static ISchemaError RequiresOptInOnRequiredArgument( .SetField(field) .SetArgument(argument) .Build(); + + public static ISchemaError RequiresOptInOnRequiredDirectiveArgument( + IDirectiveDefinition directive, + IInputValueDefinition argument) + => SchemaErrorBuilder.New() + .SetMessage(ErrorHelper_RequiresOptInOnRequiredArgument) + .SetDirective(directive) + .SetArgument(argument) + .Build(); } diff --git a/src/HotChocolate/Core/test/Execution.Tests/IntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/IntrospectionTests.cs index aca53b4fc3f..9f060fb243f 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/IntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/IntrospectionTests.cs @@ -336,12 +336,12 @@ directive @bar(baz: String, quox: SomeInput) repeatable on FIELD_DEFINITION """) .UseField(next => next) .ModifyOptions(o => o.EnableDirectiveIntrospection = true) - .AddDirectiveType(new DirectiveType(d => + .AddDirectiveType(d => { d.Name("foo"); d.Location(DirectiveLocation.FieldDefinition); d.Internal(); - })) + }) .ExecuteRequestAsync( @"{ __schema { @@ -391,12 +391,12 @@ directive @bar(baz: String, quox: SomeInput) repeatable on FIELD_DEFINITION .ModifyOptions(o => o.EnableDirectiveIntrospection = true) .ModifyOptions(o => o.DefaultDirectiveVisibility = DirectiveVisibility.Internal) .ModifyOptions(o => o.DisableInternalDirectives = true) - .AddDirectiveType(new DirectiveType(d => + .AddDirectiveType(d => { d.Name("foo"); d.Location(DirectiveLocation.FieldDefinition); d.Internal(); - })) + }) .ExecuteRequestAsync( @"{ __schema { diff --git a/src/HotChocolate/Core/test/Execution.Tests/OptInFeaturesIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/OptInFeaturesIntrospectionTests.cs index 7c58673586c..15e9557da15 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/OptInFeaturesIntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/OptInFeaturesIntrospectionTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Types; namespace HotChocolate.Execution; @@ -33,6 +34,8 @@ public async Task Execute_IntrospectionOnSchema_MatchesSnapshot() "data": { "__schema": { "optInFeatures": [ + "directiveArgFeature1", + "directiveArgFeature2", "enumValueFeature1", "enumValueFeature2", "inputFieldFeature1", @@ -590,6 +593,149 @@ public async Task Execute_IntrospectionOnEnumValuesNoIncludedFeatures_MatchesSna """); } + [Fact] + public async Task Execute_IntrospectionOnDirectiveArgs_MatchesSnapshot() + { + // arrange + const string query = + """ + { + __schema { + directives { + name + args(includeOptIn: ["directiveArgFeature1"]) { + name + requiresOptIn + } + } + } + } + """; + + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync(query, TestContext.Current.CancellationToken); + + // assert + var directive = GetDirective(result, "exampleDirective"); + directive.MatchInlineSnapshot( + """ + { + "name": "exampleDirective", + "args": [ + { + "name": "argument1", + "requiresOptIn": [ + "directiveArgFeature1", + "directiveArgFeature2" + ] + }, + { + "name": "argument2", + "requiresOptIn": [] + } + ] + } + """); + } + + [Fact] + public async Task Execute_IntrospectionOnDirectiveArgsFeatureDoesNotExist_MatchesSnapshot() + { + // arrange + const string query = + """ + { + __schema { + directives { + name + args(includeOptIn: ["directiveArgFeatureDoesNotExist"]) { + name + requiresOptIn + } + } + } + } + """; + + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync(query, TestContext.Current.CancellationToken); + + // assert + var directive = GetDirective(result, "exampleDirective"); + directive.MatchInlineSnapshot( + """ + { + "name": "exampleDirective", + "args": [ + { + "name": "argument2", + "requiresOptIn": [] + } + ] + } + """); + } + + [Fact] + public async Task Execute_IntrospectionOnDirectiveArgsNoIncludedFeatures_MatchesSnapshot() + { + // arrange + const string query = + """ + { + __schema { + directives { + name + args { + name + requiresOptIn + } + } + } + } + """; + + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync(query, TestContext.Current.CancellationToken); + + // assert + var directive = GetDirective(result, "exampleDirective"); + directive.MatchInlineSnapshot( + """ + { + "name": "exampleDirective", + "args": [ + { + "name": "argument2", + "requiresOptIn": [] + } + ] + } + """); + } + + private static readonly JsonSerializerOptions s_indented = new() { WriteIndented = true }; + + private static string GetDirective(IExecutionResult result, string directiveName) + { + using var document = JsonDocument.Parse(result.ToJson()); + + var directive = document.RootElement + .GetProperty("data") + .GetProperty("__schema") + .GetProperty("directives") + .EnumerateArray() + .Single(d => d.GetProperty("name").GetString() == directiveName); + + return JsonSerializer.Serialize(directive, s_indented); + } + private static Schema CreateSchema() { return SchemaBuilder.New() @@ -600,6 +746,7 @@ private static Schema CreateSchema() .AddQueryType() .AddType() .AddType() + .AddDirectiveType() .ModifyOptions(o => o.EnableOptInFeatures = true) .Use(next => next) .Create(); @@ -682,4 +829,23 @@ protected override void Configure(IEnumTypeDescriptor descriptor) .Deprecated(); } } + + private sealed class ExampleDirectiveType : DirectiveType + { + protected override void Configure(IDirectiveTypeDescriptor descriptor) + { + descriptor.Name("exampleDirective"); + descriptor.Location(DirectiveLocation.Field); + + descriptor + .Argument("argument1") + .Type() + .RequiresOptIn("directiveArgFeature1") + .RequiresOptIn("directiveArgFeature2"); + + descriptor + .Argument("argument2") + .Type(); + } + } } diff --git a/src/HotChocolate/Core/test/Types.Mutations.Tests/AnnotationBasedMutations.cs b/src/HotChocolate/Core/test/Types.Mutations.Tests/AnnotationBasedMutations.cs index 0aed31f67bb..9ddfbd53506 100644 --- a/src/HotChocolate/Core/test/Types.Mutations.Tests/AnnotationBasedMutations.cs +++ b/src/HotChocolate/Core/test/Types.Mutations.Tests/AnnotationBasedMutations.cs @@ -226,18 +226,17 @@ public async Task Ensure_That_Directive_Middleware_Play_Nice() .AddMutationType() .AddTypeExtension() .AddDirectiveType( - new DirectiveType( - d => - { - d.Name("foo"); - d.Location(DirectiveLocation.Field); - d.Use( - (next, _) => async context => - { - // this is just a dummy middleware - await next(context); - }); - })) + d => + { + d.Name("foo"); + d.Location(DirectiveLocation.Field); + d.Use( + (next, _) => async context => + { + // this is just a dummy middleware + await next(context); + }); + }) .AddMutationConventions( new MutationConventionOptions { ApplyToAllMutations = true }) .ModifyOptions(o => o.StrictValidation = false) diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeTrimmerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeTrimmerTests.cs index e9a7ddf4a3f..96a4d6c47f8 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeTrimmerTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeTrimmerTests.cs @@ -151,12 +151,12 @@ public void Unused_TypeSystem_Directives_Are_Removed() .Field("field") .Type() .Resolve("test")) - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("_abc") - .Location(DirectiveLocation.Object))) - .AddDirectiveType(new DirectiveType(d => d + .Location(DirectiveLocation.Object)) + .AddDirectiveType(d => d .Name("_def") - .Location(DirectiveLocation.Object))) + .Location(DirectiveLocation.Object)) .AddType() .ModifyOptions(o => o.RemoveUnreachableTypes = true) .Create(); @@ -186,9 +186,9 @@ public void Executable_Directives_Are_Never_Removed() .Field("field") .Type() .Resolve("test")) - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("_abc") - .Location(DirectiveLocation.Query))) + .Location(DirectiveLocation.Query)) .AddType() .ModifyOptions(o => o.RemoveUnreachableTypes = true) .Create(); @@ -218,9 +218,9 @@ public void Executable_Directives_Are_Never_Removed_2() .Field("field") .Type() .Resolve("test")) - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("_abc") - .Location(DirectiveLocation.Object | DirectiveLocation.Query))) + .Location(DirectiveLocation.Object | DirectiveLocation.Query)) .AddType() .ModifyOptions(o => o.RemoveUnreachableTypes = true) .Create(); @@ -251,11 +251,11 @@ public void Executable_Directives_Should_Be_Visited() .Type() .Resolve("test")) .AddType(new UuidType('D')) - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("_abc") .Location(DirectiveLocation.Object | DirectiveLocation.Query) .Argument("arg") - .Type())) + .Type()) .AddType() .ModifyOptions(o => o.RemoveUnreachableTypes = true) .Create(); diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/RequiresOptInValidation.cs b/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/RequiresOptInValidation.cs index 529d5a7d6ab..ecb4d5e8a39 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/RequiresOptInValidation.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/RequiresOptInValidation.cs @@ -80,4 +80,42 @@ type Object { } """); } + + [Fact] + public void Must_Not_Appear_On_Required_Directive_Argument() + { + ExpectError( + """ + type Query { field: Int } + + directive @example( + argument: Int! + @requiresOptIn(feature: "feature1") + @requiresOptIn(feature: "feature2")) on FIELD + """); + } + + [Fact] + public void May_Appear_On_Required_With_Default_Directive_Argument() + { + ExpectValid( + """ + type Query { field: Int } + + directive @example( + argument: Int! = 1 @requiresOptIn(feature: "feature")) on FIELD + """); + } + + [Fact] + public void May_Appear_On_Nullable_Directive_Argument() + { + ExpectValid( + """ + type Query { field: Int } + + directive @example( + argument: Int @requiresOptIn(feature: "feature")) on FIELD + """); + } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/__snapshots__/RequiresOptInValidation.Must_Not_Appear_On_Required_Directive_Argument.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/__snapshots__/RequiresOptInValidation.Must_Not_Appear_On_Required_Directive_Argument.snap new file mode 100644 index 00000000000..9a156283566 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/Validation/__snapshots__/RequiresOptInValidation.Must_Not_Appear_On_Required_Directive_Argument.snap @@ -0,0 +1,7 @@ +{ + "message": "The @requiresOptIn directive must not appear on required (non-null without a default) arguments.", + "extensions": { + "argument": "argument" + } +} + diff --git a/src/HotChocolate/Core/test/Types.Tests/Extensions/SchemaBuilderExtensions.Types.Tests.cs b/src/HotChocolate/Core/test/Types.Tests/Extensions/SchemaBuilderExtensions.Types.Tests.cs index 1326559001f..e466d7cdf66 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Extensions/SchemaBuilderExtensions.Types.Tests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Extensions/SchemaBuilderExtensions.Types.Tests.cs @@ -606,7 +606,7 @@ public void AddDirectiveType_TypeIsNull_ArgumentNullException() // act Action action = () => - SchemaBuilderExtensions.AddDirectiveType(builder, null!); + SchemaBuilderExtensions.AddDirectiveType(builder, (Type)null!); // assert Assert.Throws(action); @@ -657,6 +657,28 @@ public void AddDirectiveType_TypeIsMyDirectiveType_SchemaIsValid() builder.Create().ToString().MatchSnapshot(); } + [Fact] + public void AddDirectiveType_ConfigureDelegate_AddsDirectiveWithArgument() + { + // arrange + var builder = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("field") + .Type() + .Resolve(() => 1)); + + // act + builder.AddDirectiveType(d => d + .Name("exampleDirective") + .Location(Types.DirectiveLocation.Field) + .Argument("arg", a => a.Type())); + + // assert + var directive = builder.Create().DirectiveTypes["exampleDirective"]; + Assert.True(directive.Arguments.ContainsField("arg")); + } + [Fact] public void AddInterfaceType_BuilderIsNull_ArgumentNullException() { diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/DirectiveTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/DirectiveTypeTests.cs index a18dd576fea..bef4f6689d4 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/DirectiveTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/DirectiveTypeTests.cs @@ -434,11 +434,10 @@ public void Use2_DelegateMiddleware() .Type() .Resolve("bar")) .AddDirectiveType( - new DirectiveType( - d => d - .Name("foo") - .Location(DirectiveLocation.Object) - .Use((_, _) => _ => default))) + d => d + .Name("foo") + .Location(DirectiveLocation.Object) + .Use((_, _) => _ => default)) .Create(); // assert @@ -460,11 +459,10 @@ public void Use2_ClassMiddleware() .Type() .Resolve("bar")) .AddDirectiveType( - new DirectiveType( - d => d - .Name("foo") - .Location(DirectiveLocation.Object) - .Use())) + d => d + .Name("foo") + .Location(DirectiveLocation.Object) + .Use()) .Create(); // assert @@ -486,11 +484,10 @@ public void Use2_ClassMiddleware_WithFactory() .Type() .Resolve("bar")) .AddDirectiveType( - new DirectiveType( - d => d - .Name("foo") - .Location(DirectiveLocation.Object) - .Use((_, next) => new DirectiveMiddleware(next)))) + d => d + .Name("foo") + .Location(DirectiveLocation.Object) + .Use((_, next) => new DirectiveMiddleware(next))) .Create(); // assert @@ -512,10 +509,9 @@ static void Action() .Type() .Resolve("bar")) .AddDirectiveType( - new DirectiveType( - d => d.Name("foo") - .Location(DirectiveLocation.Object) - .Use(null!))) + d => d.Name("foo") + .Location(DirectiveLocation.Object) + .Use(null!)) .Create(); // assert @@ -618,13 +614,12 @@ public async Task CodeFirst_Deprecated_NullableArguments_Valid() .Resolve("asd") .Directive("Qux")) .AddDirectiveType( - new DirectiveType( - x => x - .Name("Qux") - .Location(DirectiveLocation.FieldDefinition) - .Argument("bar") - .Type() - .Deprecated("a"))) + x => x + .Name("Qux") + .Location(DirectiveLocation.FieldDefinition) + .Argument("bar") + .Type() + .Deprecated("a")) .BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); // assert @@ -646,13 +641,12 @@ static async Task call() .Resolve("asd") .Directive("Qux", new ArgumentNode("bar", 1))) .AddDirectiveType( - new DirectiveType( - x => x - .Name("Qux") - .Location(DirectiveLocation.FieldDefinition) - .Argument("bar") - .Type>() - .Deprecated("a"))) + x => x + .Name("Qux") + .Location(DirectiveLocation.FieldDefinition) + .Argument("bar") + .Type>() + .Deprecated("a")) .BuildRequestExecutorAsync(); // assert @@ -1187,6 +1181,30 @@ public void AnnotationBased_GraphQLDeprecatedDirectiveClass_SetsDeprecation() Assert.Equal("Use the replacement directive.", legacy.DeprecationReason); } + [Fact] + public void DirectiveArgument_AppliesDirective() + { + // arrange + // act + var schema = SchemaBuilder.New() + .AddQueryType(c => c + .Name("Query") + .Field("foo") + .Type() + .Resolve("bar")) + .AddDirectiveType(d => d + .Name("foo") + .Location(DirectiveLocation.Field) + .Argument("arg", a => a + .Type() + .Directive("deprecated"))) + .Create(); + + // assert + var argument = schema.DirectiveTypes["foo"].Arguments["arg"]; + Assert.True(argument.Directives.ContainsDirective("deprecated")); + } + public class DirectiveWithSyntaxTypeArg : DirectiveType { protected override void Configure(IDirectiveTypeDescriptor descriptor) diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/RequiresOptInDirectiveTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/RequiresOptInDirectiveTests.cs index 0bb745c6fe2..0203d2149ee 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/RequiresOptInDirectiveTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/RequiresOptInDirectiveTests.cs @@ -52,6 +52,13 @@ public async Task BuildSchemaAsync_CodeFirst_MatchesSnapshot() .Value("VALUE") .RequiresOptIn("enumValueFeature1") .RequiresOptIn("enumValueFeature2")) + .AddDirectiveType(d => d + .Name("exampleDirective") + .Location(DirectiveLocation.Field) + .Argument("argument", a => a + .Type() + .RequiresOptIn("directiveArgFeature1") + .RequiresOptIn("directiveArgFeature2"))) .BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken); // assert diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/RequiresOptInDirectiveTests.BuildSchemaAsync_CodeFirst_MatchesSnapshot.graphql b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/RequiresOptInDirectiveTests.BuildSchemaAsync_CodeFirst_MatchesSnapshot.graphql index 71c8f359bbe..a3d0eb03717 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/RequiresOptInDirectiveTests.BuildSchemaAsync_CodeFirst_MatchesSnapshot.graphql +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Directives/__snapshots__/RequiresOptInDirectiveTests.BuildSchemaAsync_CodeFirst_MatchesSnapshot.graphql @@ -20,6 +20,10 @@ enum Enum { @requiresOptIn(feature: "enumValueFeature2") } +directive @exampleDirective( + argument: Int @requiresOptIn(feature: "directiveArgFeature1") @requiresOptIn(feature: "directiveArgFeature2") +) on FIELD + "Indicates that the given field, argument, input field, or enum value requires giving explicit consent before being used." directive @requiresOptIn( "The name of the feature that requires opt in." diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/EnumTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/EnumTypeTests.cs index d34571e286c..fb07587a04c 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/EnumTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/EnumTypeTests.cs @@ -95,9 +95,9 @@ public void EnumType_WithDirectives() { // act var schema = SchemaBuilder.New() - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("bar") - .Location(DirectiveLocation.Enum))) + .Location(DirectiveLocation.Enum)) .AddEnumType(d => d.Name("Foo").Directive(new DirectiveNode("bar")).Value("ABC")) .ModifyOptions(o => o.StrictValidation = false) .Create(); @@ -357,9 +357,9 @@ public void EnumValue_WithDirectives() // act var schema = SchemaBuilder .New() - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("bar") - .Location(DirectiveLocation.EnumValue))) + .Location(DirectiveLocation.EnumValue)) .AddEnumType(d => d .Name("Foo") .Value("baz") @@ -380,9 +380,9 @@ public void EnumValue_WithDirectivesNameArgs() // act var schema = SchemaBuilder .New() - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("bar") - .Location(DirectiveLocation.EnumValue))) + .Location(DirectiveLocation.EnumValue)) .AddEnumType(d => d .Name("Foo") .Value("baz") @@ -403,9 +403,9 @@ public void Serialize_EnumValue_WithDirectives() // act var schema = SchemaBuilder .New() - .AddDirectiveType(new DirectiveType(d => d + .AddDirectiveType(d => d .Name("bar") - .Location(DirectiveLocation.EnumValue))) + .Location(DirectiveLocation.EnumValue)) .AddEnumType(d => d .Name("Foo") .Value("baz") diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Interceptors/FlagEnumInterceptorTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Interceptors/FlagEnumInterceptorTests.cs index d13b437f5be..e3fae7705f2 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Interceptors/FlagEnumInterceptorTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Interceptors/FlagEnumInterceptorTests.cs @@ -57,12 +57,11 @@ public async Task Schema_Should_Generate_Directive() x => x.Name("Query").Field("asd").Resolve("baz")) .AddDirectiveType( - new DirectiveType( - x - => x.Name("Test") - .Location(DirectiveLocation.FragmentSpread) - .Argument("a") - .Type(typeof(FlagsEnum)))) + x + => x.Name("Test") + .Location(DirectiveLocation.FragmentSpread) + .Argument("a") + .Type(typeof(FlagsEnum))) .ModifyOptions(x => x.EnableFlagEnums = true) .BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); executor.Schema.ToString().MatchSnapshot(); diff --git a/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs b/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs index 826f65bb32f..5d2f0113ba4 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs @@ -34,8 +34,8 @@ public static ISchemaBuilder AddDirectiveType( string name, DirectiveLocation location, Func configure) => - builder.AddDirectiveType(new DirectiveType(x => - configure(x.Name(name).Location(location)))); + builder.AddDirectiveType(x => + configure(x.Name(name).Location(location))); public static ISchemaDefinition CreateSchema() => SchemaBuilder.New()