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 ac296c61562..dcbadd4f986 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -9,4 +9,5 @@ public static class LogEntryCodes public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE"; 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 e49dcbf986e..8ced6e05df8 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -184,4 +184,14 @@ public static LogEntry RootQueryUsed(SchemaDefinition schema) member: schema, schema: schema); } + + public static LogEntry RootSubscriptionUsed(SchemaDefinition schema) + { + return new LogEntry( + string.Format(LogEntryHelper_RootSubscriptionUsed, schema.Name), + LogEntryCodes.RootSubscriptionUsed, + severity: LogSeverity.Error, + member: schema, + schema: schema); + } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs new file mode 100644 index 00000000000..75a3be98811 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/RootSubscriptionUsedRule.cs @@ -0,0 +1,33 @@ +using HotChocolate.Fusion.Events; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// This rule enforces that, for any source schema, if a root subscription type is defined, it must +/// be named Subscription. Defining a root subscription type with a name other than +/// Subscription or using a differently named type alongside a type explicitly named +/// Subscription creates inconsistencies in schema design and violates the composite schema +/// specification. +/// +/// +/// Specification +/// +internal sealed class RootSubscriptionUsedRule : IEventHandler +{ + public void Handle(SchemaEvent @event, CompositionContext context) + { + var schema = @event.Schema; + var rootSubscription = schema.SubscriptionType; + + if (rootSubscription is not null + && rootSubscription.Name != WellKnownTypeNames.Subscription) + { + context.Log.Write(RootSubscriptionUsed(schema)); + } + + // An object type named 'Subscription' will be set as the root subscription type if it has + // not yet been defined, so it's not necessary to check for this type in the absence of a + // root subscription type. + } +} 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 40c2840b77e..08894be8258 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 @@ -166,5 +166,14 @@ internal static string LogEntryHelper_RootQueryUsed { 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 b2bb14f1183..8fb5e617b33 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -54,4 +54,7 @@ The root query type in schema '{0}' must be named 'Query'. + + The root subscription type in schema '{0}' must be named 'Subscription'. + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs index f6e05d361e9..01222282b29 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -52,6 +52,7 @@ private CompositionResult MergeSchemaDefinitions(CompositionCo new ExternalUnusedRule(), new OutputFieldTypesMergeableRule(), new RootMutationUsedRule(), - new RootQueryUsedRule() + new RootQueryUsedRule(), + new RootSubscriptionUsedRule() ]; } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs index ae8dd6ee97e..c8726c908c3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownTypeNames.cs @@ -4,4 +4,5 @@ internal static class WellKnownTypeNames { public const string Mutation = "Mutation"; public const string Query = "Query"; + public const string Subscription = "Subscription"; } diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs new file mode 100644 index 00000000000..0e8cba67fee --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/RootSubscriptionUsedRuleTests.cs @@ -0,0 +1,97 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.PreMergeValidation.Rules; + +namespace HotChocolate.Composition.PreMergeValidation.Rules; + +public sealed class RootSubscriptionUsedRuleTests : CompositionTestBase +{ + private readonly PreMergeValidator _preMergeValidator = new([new RootSubscriptionUsedRule()]); + + [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 == "ROOT_SUBSCRIPTION_USED")); + Assert.True(context.Log.All(e => e.Severity == LogSeverity.Error)); + } + + public static TheoryData ValidExamplesData() + { + return new TheoryData + { + // Valid example. + { + [ + """ + schema { + subscription: Subscription + } + + type Subscription { + productCreated: Product + } + + type Product { + id: ID! + name: String + } + """ + ] + } + }; + } + + public static TheoryData InvalidExamplesData() + { + return new TheoryData + { + // The following example violates the rule because `RootSubscription` is used as the + // root subscription type, but a type named `Subscription` is also defined. + { + [ + """ + schema { + subscription: RootSubscription + } + + type RootSubscription { + productCreated: Product + } + + type Subscription { + deprecatedField: String + } + """ + ], + [ + "The root subscription type in schema 'A' must be named 'Subscription'." + ] + } + }; + } +}