diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Events/GroupEvents.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Events/GroupEvents.cs index 675ac7b1df6..387f669b76d 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Events/GroupEvents.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Events/GroupEvents.cs @@ -33,6 +33,10 @@ internal record OutputFieldGroupEvent( ImmutableArray FieldGroup, string TypeName) : IEvent; +internal record ScalarTypeGroupEvent( + string TypeName, + ImmutableArray TypeGroup) : IEvent; + internal record TypeGroupEvent( string TypeName, ImmutableArray TypeGroup) : IEvent; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Info/ScalarTypeInfo.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Info/ScalarTypeInfo.cs new file mode 100644 index 00000000000..8105f0c7db2 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Info/ScalarTypeInfo.cs @@ -0,0 +1,6 @@ +using HotChocolate.Types; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.Info; + +internal record ScalarTypeInfo(IScalarTypeDefinition Scalar, MutableSchemaDefinition Schema); 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 e049ecc083d..24d8e0aec71 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -66,6 +66,7 @@ public static class LogEntryCodes public const string RootMutationUsed = "ROOT_MUTATION_USED"; public const string RootQueryUsed = "ROOT_QUERY_USED"; public const string RootSubscriptionUsed = "ROOT_SUBSCRIPTION_USED"; + public const string SpecifiedByUrlMismatch = "SPECIFIED_BY_URL_MISMATCH"; public const string TypeKindMismatch = "TYPE_KIND_MISMATCH"; public const string Unsatisfiable = "UNSATISFIABLE"; } 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 133c9bb7952..2c2392402da 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -1178,6 +1178,28 @@ public static LogEntry RootSubscriptionUsed(MutableSchemaDefinition schema) .Build(); } + public static LogEntry SpecifiedByUrlMismatch( + IScalarTypeDefinition scalarType, + MutableSchemaDefinition schemaA, + string? specifiedByUrlA, + MutableSchemaDefinition schemaB, + string? specifiedByUrlB) + { + return LogEntryBuilder.New() + .SetMessage( + LogEntryHelper_SpecifiedByUrlMismatch, + scalarType.Name, + schemaA.Name, + specifiedByUrlA ?? "null", + schemaB.Name, + specifiedByUrlB ?? "null") + .SetCode(LogEntryCodes.SpecifiedByUrlMismatch) + .SetSeverity(LogSeverity.Warning) + .SetTypeSystemMember(scalarType) + .SetSchema(schemaA) + .Build(); + } + public static LogEntry TypeKindMismatch( ITypeDefinition type, MutableSchemaDefinition schemaA, diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidationRules/SpecifiedByUrlMismatchRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidationRules/SpecifiedByUrlMismatchRule.cs new file mode 100644 index 00000000000..391cb564ddb --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidationRules/SpecifiedByUrlMismatchRule.cs @@ -0,0 +1,44 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidationRules; + +/// +/// +/// This validation rule checks for mismatches in the specified-by URLs of scalar types across +/// different schemas. If a scalar type is defined in multiple schemas with different specified-by +/// URLs, a warning is logged to indicate the inconsistency. This helps ensure that clients +/// consuming the merged schema have a consistent understanding of the scalar type's behavior and +/// specifications, even if the underlying implementations differ across schemas. +/// +/// +/// +/// Specification +/// +internal sealed class SpecifiedByUrlMismatchRule : IEventHandler +{ + public void Handle(ScalarTypeGroupEvent @event, CompositionContext context) + { + var (_, typeGroup) = @event; + + for (var i = 0; i < typeGroup.Length - 1; i++) + { + var typeInfoA = typeGroup[i]; + var typeInfoB = typeGroup[i + 1]; + var specifiedByA = typeInfoA.Scalar.SpecifiedBy; + var specifiedByB = typeInfoB.Scalar.SpecifiedBy; + + if (specifiedByA != specifiedByB) + { + context.Log.Write( + SpecifiedByUrlMismatch( + typeInfoA.Scalar, + typeInfoA.Schema, + specifiedByA?.ToString(), + typeInfoB.Schema, + specifiedByB?.ToString())); + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidator.cs index 6dba414fbe0..11fbe2851e5 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidator.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidator.cs @@ -52,6 +52,7 @@ private void PublishEvents() MultiValueDictionary outputFieldGroupByName = []; MultiValueDictionary objectFieldGroupByName = []; MultiValueDictionary enumTypeGroupByName = []; + MultiValueDictionary scalarTypeGroupByName = []; foreach (var (type, schema) in typeGroup) { @@ -96,6 +97,10 @@ private void PublishEvents() case MutableEnumTypeDefinition enumType: enumTypeGroupByName.Add(enumType.Name, new EnumTypeInfo(enumType, schema)); break; + + case MutableScalarTypeDefinition scalarType: + scalarTypeGroupByName.Add(scalarType.Name, new ScalarTypeInfo(scalarType, schema)); + break; } } @@ -149,6 +154,11 @@ private void PublishEvents() { PublishEvent(new EnumTypeGroupEvent(enumName, [.. enumGroup]), context); } + + foreach (var (scalarName, scalarGroup) in scalarTypeGroupByName) + { + PublishEvent(new ScalarTypeGroupEvent(scalarName, [.. scalarGroup]), context); + } } } 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 56320666f7c..4c49b02d675 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 @@ -1285,6 +1285,15 @@ internal static string LogEntryHelper_RootSubscriptionUsed { } } + /// + /// Looks up a localized string similar to The scalar type '{0}' has a different specified-by URL in schema '{1}' ({2}) than it does in schema '{3}' ({4}).. + /// + internal static string LogEntryHelper_SpecifiedByUrlMismatch { + get { + return ResourceManager.GetString("LogEntryHelper_SpecifiedByUrlMismatch", resourceCulture); + } + } + /// /// Looks up a localized string similar to The type '{0}' has a different kind in schema '{1}' ({2}) than it does in schema '{3}' ({4}).. /// 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 fd4f2d689af..48d54b4dabf 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -423,6 +423,9 @@ The root subscription type in schema '{0}' must be named 'Subscription'. + + The scalar type '{0}' has a different specified-by URL in schema '{1}' ({2}) than it does in schema '{3}' ({4}). + The type '{0}' has a different kind in schema '{1}' ({2}) than it does in schema '{3}' ({4}). diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs index 09713bc3209..aa8a1542628 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs @@ -180,6 +180,7 @@ public CompositionResult Compose() new InputWithMissingOneOfRule(), new InvalidFieldSharingRule(), new OutputFieldTypesMergeableRule(), + new SpecifiedByUrlMismatchRule(), new TypeKindMismatchRule() ]; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidationRules/SpecifiedByUrlMismatchRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidationRules/SpecifiedByUrlMismatchRuleTests.cs new file mode 100644 index 00000000000..5acbb4e1e48 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidationRules/SpecifiedByUrlMismatchRuleTests.cs @@ -0,0 +1,69 @@ +namespace HotChocolate.Fusion.PreMergeValidationRules; + +public sealed class SpecifiedByUrlMismatchRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new SpecifiedByUrlMismatchRule(); + + // All schemas have the same specified-by URL for the "Date" scalar type. + [Fact] + public void Validate_SpecifiedByUrlMatch_Succeeds() + { + AssertValid( + [ + """ + # Schema A + scalar Date @specifiedBy(url: "https://example.com/date-spec") + """, + """ + # Schema B + scalar Date @specifiedBy(url: "https://example.com/date-spec") + """ + ]); + } + + // The schemas have different specified-by URLs for the "Date" scalar type, which should trigger + // warnings. + [Fact] + public void Validate_SpecifiedByUrlMismatch_Fails() + { + AssertInvalid( + [ + """ + # Schema A + scalar Date @specifiedBy(url: "https://example.com/date-spec-1") + """, + """ + # Schema B + scalar Date @specifiedBy(url: "https://example.com/date-spec-2") + """, + """ + # Schema C + scalar Date + """ + ], + [ + """ + { + "message": "The scalar type 'Date' has a different specified-by URL in schema 'A' (https://example.com/date-spec-1) than it does in schema 'B' (https://example.com/date-spec-2).", + "code": "SPECIFIED_BY_URL_MISMATCH", + "severity": "Warning", + "coordinate": "Date", + "member": "Date", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The scalar type 'Date' has a different specified-by URL in schema 'B' (https://example.com/date-spec-2) than it does in schema 'C' (null).", + "code": "SPECIFIED_BY_URL_MISMATCH", + "severity": "Warning", + "coordinate": "Date", + "member": "Date", + "schema": "B", + "extensions": {} + } + """ + ]); + } +}