From a1638029a7aed61f20e953fc87c7dd820f9298c7 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 1 Jan 2025 12:40:51 +0200 Subject: [PATCH] [Fusion] Added pre-merge validation rule "QueryRootTypeInaccessibleRule" --- .../Logging/LogEntryCodes.cs | 1 + .../Logging/LogEntryHelper.cs | 13 ++ .../Rules/QueryRootTypeInaccessibleRule.cs | 27 ++++ .../CompositionResources.Designer.cs | 123 +++++++++++++++--- .../Properties/CompositionResources.resx | 3 + .../Fusion.Composition/SourceSchemaMerger.cs | 1 + .../QueryRootTypeInaccessibleRuleTests.cs | 101 ++++++++++++++ 7 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/QueryRootTypeInaccessibleRule.cs create mode 100644 src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/QueryRootTypeInaccessibleRuleTests.cs diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs index ec03940ad15..86a844469e5 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -14,6 +14,7 @@ public static class LogEntryCodes public const string KeyInvalidSyntax = "KEY_INVALID_SYNTAX"; public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE"; public const string ProvidesDirectiveInFieldsArg = "PROVIDES_DIRECTIVE_IN_FIELDS_ARG"; + public const string QueryRootTypeInaccessible = "QUERY_ROOT_TYPE_INACCESSIBLE"; public const string RootMutationUsed = "ROOT_MUTATION_USED"; public const string RootQueryUsed = "ROOT_QUERY_USED"; public const string RootSubscriptionUsed = "ROOT_SUBSCRIPTION_USED"; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs index 982e81a4a6f..ee0578105d6 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -300,6 +300,19 @@ public static LogEntry ProvidesDirectiveInFieldsArgument( schema); } + public static LogEntry QueryRootTypeInaccessible( + INamedTypeDefinition type, + SchemaDefinition schema) + { + return new LogEntry( + string.Format(LogEntryHelper_QueryRootTypeInaccessible, schema.Name), + LogEntryCodes.QueryRootTypeInaccessible, + LogSeverity.Error, + new SchemaCoordinate(type.Name), + type, + schema); + } + public static LogEntry RootMutationUsed(SchemaDefinition schema) { return new LogEntry( diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/QueryRootTypeInaccessibleRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/QueryRootTypeInaccessibleRule.cs new file mode 100644 index 00000000000..8b27168556f --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/QueryRootTypeInaccessibleRule.cs @@ -0,0 +1,27 @@ +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// Every source schema that contributes to the final composite schema must expose a public +/// (accessible) root query type. Marking the root query type as @inaccessible makes it +/// invisible to the gateway, defeating its purpose as the primary entry point for queries and +/// lookups. +/// +/// +/// Specification +/// +internal sealed class QueryRootTypeInaccessibleRule : IEventHandler +{ + public void Handle(SchemaEvent @event, CompositionContext context) + { + var schema = @event.Schema; + var rootQuery = schema.QueryType; + + if (rootQuery is not null && !ValidationHelper.IsAccessible(rootQuery)) + { + context.Log.Write(QueryRootTypeInaccessible(rootQuery, schema)); + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index b28bde6daf0..5c33776e200 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -11,32 +11,46 @@ namespace HotChocolate.Fusion.Properties { using System; - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [System.Diagnostics.DebuggerNonUserCodeAttribute()] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class CompositionResources { - private static System.Resources.ResourceManager resourceMan; + private static global::System.Resources.ResourceManager resourceMan; - private static System.Globalization.CultureInfo resourceCulture; + private static global::System.Globalization.CultureInfo resourceCulture; - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal CompositionResources() { } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Resources.ResourceManager ResourceManager { + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { get { - if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("HotChocolate.Fusion.Properties.CompositionResources", typeof(CompositionResources).Assembly); + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.Fusion.Properties.CompositionResources", typeof(CompositionResources).Assembly); resourceMan = temp; } return resourceMan; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Globalization.CultureInfo Culture { + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -45,120 +59,189 @@ internal static System.Globalization.CultureInfo Culture { } } + /// + /// Looks up a localized string similar to Pre-merge validation failed. View the composition log for details.. + /// internal static string ErrorHelper_PreMergeValidationFailed { get { return ResourceManager.GetString("ErrorHelper_PreMergeValidationFailed", resourceCulture); } } + /// + /// Looks up a localized string similar to The built-in scalar type '{0}' in schema '{1}' is not accessible.. + /// internal static string LogEntryHelper_DisallowedInaccessibleBuiltInScalar { get { return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleBuiltInScalar", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { + /// + /// Looks up a localized string similar to The built-in directive argument '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleDirectiveArgument { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionType", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleDirectiveArgument", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionField { + /// + /// Looks up a localized string similar to The introspection argument '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionArgument { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionField", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionArgument", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionArgument { + /// + /// Looks up a localized string similar to The introspection field '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionField { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionArgument", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionField", resourceCulture); } } - internal static string LogEntryHelper_DisallowedInaccessibleDirectiveArgument { + /// + /// Looks up a localized string similar to The introspection type '{0}' in schema '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { get { - return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleDirectiveArgument", resourceCulture); + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionType", resourceCulture); } } + /// + /// Looks up a localized string similar to The argument with schema coordinate '{0}' has inconsistent default values.. + /// internal static string LogEntryHelper_ExternalArgumentDefaultMismatch { get { return ResourceManager.GetString("LogEntryHelper_ExternalArgumentDefaultMismatch", resourceCulture); } } + /// + /// Looks up a localized string similar to External field '{0}' in schema '{1}' is not defined (non-external) in any other schema.. + /// internal static string LogEntryHelper_ExternalMissingOnBase { get { return ResourceManager.GetString("LogEntryHelper_ExternalMissingOnBase", resourceCulture); } } + /// + /// Looks up a localized string similar to Interface field '{0}' in schema '{1}' must not be marked as external.. + /// internal static string LogEntryHelper_ExternalOnInterface { get { return ResourceManager.GetString("LogEntryHelper_ExternalOnInterface", resourceCulture); } } + /// + /// Looks up a localized string similar to External field '{0}' in schema '{1}' is not referenced by a @provides directive in the schema.. + /// internal static string LogEntryHelper_ExternalUnused { get { return ResourceManager.GetString("LogEntryHelper_ExternalUnused", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not include directive applications.. + /// internal static string LogEntryHelper_KeyDirectiveInFieldsArgument { get { return ResourceManager.GetString("LogEntryHelper_KeyDirectiveInFieldsArgument", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not have arguments.. + /// internal static string LogEntryHelper_KeyFieldsHasArguments { get { return ResourceManager.GetString("LogEntryHelper_KeyFieldsHasArguments", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which must not be a list, interface, or union type.. + /// internal static string LogEntryHelper_KeyFieldsSelectInvalidType { get { return ResourceManager.GetString("LogEntryHelper_KeyFieldsSelectInvalidType", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' references field '{2}', which does not exist.. + /// internal static string LogEntryHelper_KeyInvalidFields { get { return ResourceManager.GetString("LogEntryHelper_KeyInvalidFields", resourceCulture); } } + /// + /// Looks up a localized string similar to A @key directive on type '{0}' in schema '{1}' contains invalid syntax in the 'fields' argument.. + /// internal static string LogEntryHelper_KeyInvalidSyntax { get { return ResourceManager.GetString("LogEntryHelper_KeyInvalidSyntax", resourceCulture); } } + /// + /// Looks up a localized string similar to Field '{0}' has a different type shape in schema '{1}' than it does in schema '{2}'.. + /// internal static string LogEntryHelper_OutputFieldTypesNotMergeable { get { return ResourceManager.GetString("LogEntryHelper_OutputFieldTypesNotMergeable", resourceCulture); } } + /// + /// Looks up a localized string similar to The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications.. + /// internal static string LogEntryHelper_ProvidesDirectiveInFieldsArgument { get { return ResourceManager.GetString("LogEntryHelper_ProvidesDirectiveInFieldsArgument", resourceCulture); } } + /// + /// Looks up a localized string similar to The root query type in schema '{0}' must be accessible.. + /// + internal static string LogEntryHelper_QueryRootTypeInaccessible { + get { + return ResourceManager.GetString("LogEntryHelper_QueryRootTypeInaccessible", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The root mutation type in schema '{0}' must be named 'Mutation'.. + /// internal static string LogEntryHelper_RootMutationUsed { get { return ResourceManager.GetString("LogEntryHelper_RootMutationUsed", resourceCulture); } } + /// + /// Looks up a localized string similar to The root query type in schema '{0}' must be named 'Query'.. + /// internal static string LogEntryHelper_RootQueryUsed { get { return ResourceManager.GetString("LogEntryHelper_RootQueryUsed", resourceCulture); } } + /// + /// Looks up a localized string similar to The root subscription type in schema '{0}' must be named 'Subscription'.. + /// internal static string LogEntryHelper_RootSubscriptionUsed { get { return ResourceManager.GetString("LogEntryHelper_RootSubscriptionUsed", resourceCulture); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx index a5cf669812b..55557501fc8 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -69,6 +69,9 @@ The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must not include directive applications. + + The root query type in schema '{0}' must be accessible. + The root mutation type in schema '{0}' must be named 'Mutation'. diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index 522f73dde0c..7ca6310af2c 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -58,6 +58,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo new KeyInvalidSyntaxRule(), new OutputFieldTypesMergeableRule(), new ProvidesDirectiveInFieldsArgumentRule(), + new QueryRootTypeInaccessibleRule(), new RootMutationUsedRule(), new RootQueryUsedRule(), new RootSubscriptionUsedRule() diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/QueryRootTypeInaccessibleRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/QueryRootTypeInaccessibleRuleTests.cs new file mode 100644 index 00000000000..34552d7dcd2 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/QueryRootTypeInaccessibleRuleTests.cs @@ -0,0 +1,101 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class QueryRootTypeInaccessibleRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = + new([new QueryRootTypeInaccessibleRule()]); + + [Theory] + [MemberData(nameof(ValidExamplesData))] + public void Examples_Valid(string[] sdl) + { + // arrange + var context = CreateCompositionContext(sdl); + + // act + var result = _preMergeValidator.Validate(context); + + // assert + Assert.True(result.IsSuccess); + Assert.True(context.Log.IsEmpty); + } + + [Theory] + [MemberData(nameof(InvalidExamplesData))] + public void Examples_Invalid(string[] sdl, string[] errorMessages) + { + // arrange + var context = CreateCompositionContext(sdl); + + // act + var result = _preMergeValidator.Validate(context); + + // assert + Assert.True(result.IsFailure); + Assert.Equal(errorMessages, context.Log.Select(e => e.Message).ToArray()); + Assert.True(context.Log.All(e => e.Code == "QUERY_ROOT_TYPE_INACCESSIBLE")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + // In this example, no @inaccessible annotation is applied to the query root, so the + // rule is satisfied. + { + [ + """ + extend schema { + query: Query + } + + type Query { + allBooks: [Book] + } + + type Book { + id: ID! + title: String + } + """ + ] + } + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // Since the schema marks the query root type as @inaccessible, the rule is violated. + // QUERY_ROOT_TYPE_INACCESSIBLE is raised because a schema’s root query type cannot be + // hidden from consumers. + { + [ + """ + extend schema { + query: Query + } + + type Query @inaccessible { + allBooks: [Book] + } + + type Book { + id: ID! + title: String + } + """ + ], + [ + "The root query type in schema 'A' must be accessible." + ] + } + }; + } +}