From f7096e8c2f29c8444b248f3e7eb2c6c3aca242dd Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 1 Jan 2025 16:23:26 +0200 Subject: [PATCH] [Fusion] Added pre-merge validation rule "QueryRootTypeInaccessibleRule" (#7886) --- .../Logging/LogEntryCodes.cs | 1 + .../Logging/LogEntryHelper.cs | 13 +++ .../Rules/QueryRootTypeInaccessibleRule.cs | 27 +++++ .../CompositionResources.Designer.cs | 9 ++ .../Properties/CompositionResources.resx | 3 + .../Fusion.Composition/SourceSchemaMerger.cs | 1 + .../QueryRootTypeInaccessibleRuleTests.cs | 101 ++++++++++++++++++ 7 files changed, 155 insertions(+) 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 901dd55e299..7743e2093e9 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -16,6 +16,7 @@ public static class LogEntryCodes public const string ProvidesDirectiveInFieldsArg = "PROVIDES_DIRECTIVE_IN_FIELDS_ARG"; public const string ProvidesFieldsHasArgs = "PROVIDES_FIELDS_HAS_ARGS"; public const string ProvidesFieldsMissingExternal = "PROVIDES_FIELDS_MISSING_EXTERNAL"; + 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 033f5d0a955..8e1ea275eef 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -346,6 +346,19 @@ public static LogEntry ProvidesFieldsMissingExternal( 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 e7aba369147..921b615f5e9 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 @@ -230,6 +230,15 @@ internal static string LogEntryHelper_ProvidesFieldsMissingExternal { } } + /// + /// 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'.. /// 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 41a8ac9b472..e57690bab1a 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -75,6 +75,9 @@ The @provides directive on field '{0}' in schema '{1}' references field '{2}', which must be marked as external. + + 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 2b72ad33037..0d1fd80c066 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -60,6 +60,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo new ProvidesDirectiveInFieldsArgumentRule(), new ProvidesFieldsHasArgumentsRule(), new ProvidesFieldsMissingExternalRule(), + 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." + ] + } + }; + } +}